diff --git a/loadgen-rust/.gitignore b/loadgen-rust/.gitignore
new file mode 100644
index 0000000..2f7896d
--- /dev/null
+++ b/loadgen-rust/.gitignore
@@ -0,0 +1 @@
+target/
diff --git a/loadgen-rust/Cargo.lock b/loadgen-rust/Cargo.lock
new file mode 100644
index 0000000..a560cc3
--- /dev/null
+++ b/loadgen-rust/Cargo.lock
@@ -0,0 +1,2872 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "assert-json-diff"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "async-compression"
+version = "0.4.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1"
+dependencies = [
+ "compression-codecs",
+ "compression-core",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
+name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
+
+[[package]]
+name = "block2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
+dependencies = [
+ "objc2",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
+[[package]]
+name = "bytesize"
+version = "1.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e93abca9e28e0a1b9877922aacb20576e05d4679ffa78c3d6dc22a26a216659"
+
+[[package]]
+name = "cc"
+version = "1.2.60"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+[[package]]
+name = "chrono"
+version = "0.4.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "serde",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
+name = "clap"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "colored"
+version = "3.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "compression-codecs"
+version = "0.4.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7"
+dependencies = [
+ "compression-core",
+ "flate2",
+ "memchr",
+]
+
+[[package]]
+name = "compression-core"
+version = "0.4.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
+
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[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"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "ctrlc"
+version = "3.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
+dependencies = [
+ "dispatch2",
+ "nix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "dashmap"
+version = "6.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+ "hashbrown 0.14.5",
+ "lock_api",
+ "once_cell",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "dispatch2"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
+dependencies = [
+ "bitflags",
+ "block2",
+ "libc",
+ "objc2",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "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"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-timer"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+ "wasip2",
+ "wasip3",
+]
+
+[[package]]
+name = "governor"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0746aa765db78b521451ef74221663b57ba595bf83f75d0ce23cc09447c8139f"
+dependencies = [
+ "cfg-if",
+ "dashmap",
+ "futures-sink",
+ "futures-timer",
+ "futures-util",
+ "no-std-compat",
+ "nonzero_ext",
+ "parking_lot",
+ "portable-atomic",
+ "quanta",
+ "rand 0.8.5",
+ "smallvec",
+ "spinning_top",
+]
+
+[[package]]
+name = "h2"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
+
+[[package]]
+name = "hdrhistogram"
+version = "7.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d"
+dependencies = [
+ "base64 0.21.7",
+ "byteorder",
+ "crossbeam-channel",
+ "flate2",
+ "nom",
+ "num-traits",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "http"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "hyper"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "h2",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2b52f86d1d4bc0d6b4e6826d960b1b333217e07d36b882dca570a5e1c48895b"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "webpki-roots",
+]
+
+[[package]]
+name = "hyper-tls"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
+dependencies = [
+ "bytes",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "native-tls",
+ "tokio",
+ "tokio-native-tls",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "system-configuration",
+ "tokio",
+ "tower-service",
+ "tracing",
+ "windows-registry",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "id-arena"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.0",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+
+[[package]]
+name = "iri-string"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.95"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
+[[package]]
+name = "libc"
+version = "0.2.185"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "loadgen"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "base64 0.22.1",
+ "bytesize",
+ "chrono",
+ "clap",
+ "ctrlc",
+ "flate2",
+ "governor",
+ "hdrhistogram",
+ "mockito",
+ "parking_lot",
+ "rand 0.8.5",
+ "regex",
+ "reqwest",
+ "roaring",
+ "serde",
+ "serde_json",
+ "serde_yaml",
+ "tempfile",
+ "thiserror 1.0.69",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
+ "uuid",
+]
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "mockito"
+version = "1.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0"
+dependencies = [
+ "assert-json-diff",
+ "bytes",
+ "colored",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "log",
+ "pin-project-lite",
+ "rand 0.9.4",
+ "regex",
+ "serde_json",
+ "serde_urlencoded",
+ "similar",
+ "tokio",
+]
+
+[[package]]
+name = "native-tls"
+version = "0.2.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
+dependencies = [
+ "libc",
+ "log",
+ "openssl",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "security-framework",
+ "security-framework-sys",
+ "tempfile",
+]
+
+[[package]]
+name = "nix"
+version = "0.31.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
+[[package]]
+name = "no-std-compat"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "nonzero_ext"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "objc2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+dependencies = [
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "openssl"
+version = "0.10.77"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f"
+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.113"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "portable-atomic"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quanta"
+version = "0.12.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
+dependencies = [
+ "crossbeam-utils",
+ "libc",
+ "once_cell",
+ "raw-cpuid",
+ "wasi",
+ "web-sys",
+ "winapi",
+]
+
+[[package]]
+name = "quinn"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
+dependencies = [
+ "bytes",
+ "getrandom 0.3.4",
+ "lru-slab",
+ "rand 0.9.4",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror 2.0.18",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+dependencies = [
+ "libc",
+ "rand_chacha 0.3.1",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "raw-cpuid"
+version = "11.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "regex"
+version = "1.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "encoding_rs",
+ "futures-core",
+ "futures-util",
+ "h2",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-tls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "mime",
+ "native-tls",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-native-tls",
+ "tokio-rustls",
+ "tokio-util",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
+ "webpki-roots",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "roaring"
+version = "0.10.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19e8d2cfa184d94d0726d650a9f4a1be7f9b76ac9fdb954219878dc00c1c1e7b"
+dependencies = [
+ "bytemuck",
+ "byteorder",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.38"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21"
+dependencies = [
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "schannel"
+version = "0.1.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "security-framework"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
+dependencies = [
+ "bitflags",
+ "core-foundation 0.10.1",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serde_yaml"
+version = "0.9.34+deprecated"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
+dependencies = [
+ "indexmap",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
+
+[[package]]
+name = "similar"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "socket2"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "spinning_top"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
+dependencies = [
+ "lock_api",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "system-configuration"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
+dependencies = [
+ "bitflags",
+ "core-foundation 0.9.4",
+ "system-configuration-sys",
+]
+
+[[package]]
+name = "system-configuration-sys"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.2",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl 2.0.18",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.51.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-native-tls"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
+dependencies = [
+ "native-tls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
+dependencies = [
+ "async-compression",
+ "bitflags",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "iri-string",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+ "tower",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "unsafe-libyaml"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "uuid"
+version = "1.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
+dependencies = [
+ "getrandom 0.4.2",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.2+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.68"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap",
+ "wasm-encoder",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-streams"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "semver",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.95"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-registry"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
+dependencies = [
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro",
+]
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck",
+ "indexmap",
+ "prettyplease",
+ "syn",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags",
+ "indexmap",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "yoke"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/loadgen-rust/Cargo.toml b/loadgen-rust/Cargo.toml
new file mode 100644
index 0000000..833c046
--- /dev/null
+++ b/loadgen-rust/Cargo.toml
@@ -0,0 +1,109 @@
+# Copyright (C) INFINI Labs & INFINI LIMITED.
+#
+# The INFINI Loadgen is offered under the GNU Affero General Public License v3.0
+# and as commercial software.
+#
+# For commercial licensing, contact us at:
+# - Website: infinilabs.com
+# - Email: hello@infini.ltd
+#
+# Open Source licensed under AGPL V3:
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+[package]
+name = "loadgen"
+version = "0.1.0"
+edition = "2021"
+authors = ["INFINI Labs "]
+description = "A high-performance HTTP load generator and testing suite"
+license = "AGPL-3.0"
+homepage = "https://github.com/infinilabs/loadgen"
+repository = "https://github.com/infinilabs/loadgen"
+keywords = ["load-testing", "http", "benchmark", "performance"]
+categories = ["command-line-utilities", "development-tools::testing"]
+
+[dependencies]
+# Async runtime
+tokio = { version = "1", features = ["full", "sync", "time", "signal", "rt-multi-thread", "macros"] }
+
+# CLI argument parsing
+clap = { version = "4", features = ["derive", "env"] }
+
+# Serialization
+serde = { version = "1", features = ["derive"] }
+serde_yaml = "0.9"
+serde_json = "1"
+
+# HTTP client
+reqwest = { version = "0.12", features = ["gzip", "rustls-tls", "json", "stream"] }
+
+# UUID generation
+uuid = { version = "1", features = ["v4"] }
+
+# Time handling
+chrono = { version = "0.4", features = ["serde"] }
+
+# Random number generation
+rand = "0.8"
+
+# Roaring bitmap for int_array_bitmap variable type
+roaring = "0.10"
+
+# Base64 encoding for bitmap serialization
+base64 = "0.22"
+
+# Regex for assertions and DSL parsing
+regex = "1"
+
+# Histogram for latency metrics
+hdrhistogram = "7"
+
+# Rate limiting
+governor = "0.7"
+
+# Logging
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+
+# Synchronization primitives
+parking_lot = "0.12"
+
+# Compression
+flate2 = "1"
+
+# Error handling
+thiserror = "1"
+anyhow = "1"
+
+# Signal handling (Ctrl+C)
+ctrlc = { version = "3", features = ["termination"] }
+
+# Formatting byte sizes
+bytesize = "1"
+
+[dev-dependencies]
+# Testing utilities
+tempfile = "3"
+mockito = "1"
+
+[profile.release]
+lto = true
+codegen-units = 1
+panic = "abort"
+strip = true
+opt-level = 3
+
+[profile.bench]
+lto = true
+codegen-units = 1
diff --git a/loadgen-rust/README.md b/loadgen-rust/README.md
new file mode 100644
index 0000000..9a16761
--- /dev/null
+++ b/loadgen-rust/README.md
@@ -0,0 +1,152 @@
+# INFINI Loadgen (Rust)
+
+A high-performance HTTP load generator and testing suite written in Rust, compatible with the original Go implementation's configuration files.
+
+## Features
+
+- **High Performance**: Built with Tokio async runtime for maximum throughput
+- **Compatible Configuration**: Supports the same YAML and DSL configuration formats as the Go version
+- **Variable System**: Full support for all variable types including:
+ - `file` - Load from external files
+ - `list` - Inline defined lists
+ - `sequence` / `sequence64` - Auto-incrementing sequences
+ - `range` - Random numbers in range
+ - `uuid` - UUID v4 generation
+ - `now_local`, `now_utc`, `now_unix` - Time variables
+ - `now_with_format` - Custom formatted time
+ - `random_array` - Random arrays from other variables
+ - `int_array_bitmap` - Roaring bitmap encoded arrays
+- **Template Engine**: `$[[variable]]` syntax for dynamic request generation
+- **Response Assertions**: Validate responses with conditions (equals, contains, regexp, range, etc.)
+- **Rate Limiting**: Control request rate with `-r` flag
+- **Metrics**: Detailed latency histograms and percentiles
+
+## Building
+
+```bash
+cd loadgen-rust
+cargo build --release
+```
+
+The binary will be at `target/release/loadgen`.
+
+## Usage
+
+```bash
+# Run with default config (loadgen.yml)
+./loadgen
+
+# Run with specific config
+./loadgen -C myconfig.yml
+
+# Run DSL file
+./loadgen --run test.dsl
+
+# Load test with concurrency
+./loadgen -c 10 -d 30 -r 1000
+
+# Mixed mode (run both DSL and YAML)
+./loadgen --run test.dsl -C config.yml --mixed
+```
+
+## CLI Options
+
+| Flag | Description | Default |
+|------|-------------|---------|
+| `-c, --concurrency` | Number of concurrent threads | 1 |
+| `-d, --duration` | Test duration in seconds | 5 |
+| `-r, --rate` | Max requests per second (-1 = unlimited) | -1 |
+| `-l, --limit` | Total request limit (-1 = unlimited) | -1 |
+| `--timeout` | Request timeout in seconds (0 = no timeout) | 0 |
+| `--read-timeout` | Read timeout in seconds | 0 |
+| `--write-timeout` | Write timeout in seconds | 0 |
+| `--dial-timeout` | Connection dial timeout in seconds | 3 |
+| `--compress` | Enable gzip compression | false |
+| `--mixed` | Enable mixed YAML/DSL mode | false |
+| `--total-rounds` | Number of request rounds (-1 = unlimited) | -1 |
+| `--run` | Path to DSL file | - |
+| `-C, --config` | Path to YAML config | loadgen.yml |
+| `--log` | Log level (trace, debug, info, warn, error) | info |
+| `--debug` | Enable debug mode | false |
+
+## Configuration Format
+
+### YAML Configuration (`loadgen.yml`)
+
+```yaml
+env:
+ ES_USERNAME: elastic
+ ES_PASSWORD: password
+ ES_ENDPOINT: http://localhost:9200
+
+runner:
+ total_rounds: 1
+ no_warm: true
+ log_requests: false
+ assert_invalid: false
+ assert_error: false
+ default_endpoint: $[[env.ES_ENDPOINT]]
+ default_basic_auth:
+ username: $[[env.ES_USERNAME]]
+ password: $[[env.ES_PASSWORD]]
+
+variables:
+ - name: id
+ type: sequence
+ - name: uuid
+ type: uuid
+ - name: user
+ type: list
+ data:
+ - alice
+ - bob
+ - charlie
+
+requests:
+ - request:
+ method: POST
+ url: /_bulk
+ body: |
+ {"index": {"_index": "test", "_id": "$[[uuid]]"}}
+ {"id": "$[[id]]", "user": "$[[user]]"}
+```
+
+### DSL Configuration
+
+```dsl
+# runner: {"total_rounds": 1, "no_warm": true}
+# variables: [{"name": "id", "type": "sequence"}]
+
+DELETE /test
+
+PUT /test
+
+POST /test/_doc/$[[id]]
+{
+ "name": "test document"
+}
+
+GET /test/_search
+# 200
+```
+
+## Response Assertions
+
+```yaml
+requests:
+ - request:
+ method: GET
+ url: /test/_search
+ assert:
+ equals:
+ _ctx.response.status: 200
+ contains:
+ _ctx.response.body: "hits"
+ range:
+ _ctx.elapsed:
+ lte: 1000
+```
+
+## License
+
+AGPL-3.0 - See [LICENSE](../LICENSE) for details.
diff --git a/loadgen-rust/src/assertion/conditions.rs b/loadgen-rust/src/assertion/conditions.rs
new file mode 100644
index 0000000..d95d6f2
--- /dev/null
+++ b/loadgen-rust/src/assertion/conditions.rs
@@ -0,0 +1,345 @@
+// Copyright (C) INFINI Labs & INFINI LIMITED.
+//
+// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0
+// and as commercial software.
+//
+// For commercial licensing, contact us at:
+// - Website: infinilabs.com
+// - Email: hello@infini.ltd
+//
+// Open Source licensed under AGPL V3:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Assertion conditions for response validation
+
+use std::collections::HashMap;
+
+use regex::Regex;
+use tracing::debug;
+
+use crate::config::types::AssertConfig;
+
+/// Assertion checker for validating responses
+pub struct AssertionChecker<'a> {
+ ctx: &'a HashMap,
+}
+
+impl<'a> AssertionChecker<'a> {
+ /// Create a new assertion checker
+ pub fn new(ctx: &'a HashMap) -> Self {
+ Self { ctx }
+ }
+
+ /// Check all assertions in the config
+ pub fn check(&self, config: &AssertConfig) -> bool {
+ // Check equals
+ for (field, expected) in &config.equals {
+ if !self.check_equals(field, expected) {
+ debug!("equals assertion failed for {}: expected {:?}", field, expected);
+ return false;
+ }
+ }
+
+ // Check not_equals
+ for (field, expected) in &config.not_equals {
+ if self.check_equals(field, expected) {
+ debug!("not_equals assertion failed for {}", field);
+ return false;
+ }
+ }
+
+ // Check contains
+ for (field, expected) in &config.contains {
+ if !self.check_contains(field, expected) {
+ debug!("contains assertion failed for {}: expected to contain {:?}", field, expected);
+ return false;
+ }
+ }
+
+ // Check not_contains
+ for (field, expected) in &config.not_contains {
+ if self.check_contains(field, expected) {
+ debug!("not_contains assertion failed for {}", field);
+ return false;
+ }
+ }
+
+ // Check regexp
+ for (field, pattern) in &config.regexp {
+ if !self.check_regexp(field, pattern) {
+ debug!("regexp assertion failed for {}: pattern {:?}", field, pattern);
+ return false;
+ }
+ }
+
+ // Check not_regexp
+ for (field, pattern) in &config.not_regexp {
+ if self.check_regexp(field, pattern) {
+ debug!("not_regexp assertion failed for {}", field);
+ return false;
+ }
+ }
+
+ // Check range
+ for (field, range) in &config.range {
+ if !self.check_range(field, range) {
+ debug!("range assertion failed for {}", field);
+ return false;
+ }
+ }
+
+ // Check in
+ for (field, values) in &config.in_list {
+ if !self.check_in(field, values) {
+ debug!("in assertion failed for {}", field);
+ return false;
+ }
+ }
+
+ // Check not_in
+ for (field, values) in &config.not_in {
+ if self.check_in(field, values) {
+ debug!("not_in assertion failed for {}", field);
+ return false;
+ }
+ }
+
+ true
+ }
+
+ /// Get a value from the context
+ fn get_value(&self, field: &str) -> Option<&serde_json::Value> {
+ // Direct lookup
+ if let Some(value) = self.ctx.get(field) {
+ return Some(value);
+ }
+
+ // Try nested lookup for body_json paths
+ if field.starts_with("_ctx.response.body_json.") {
+ if let Some(body_json) = self.ctx.get("_ctx.response.body_json") {
+ let path = field.strip_prefix("_ctx.response.body_json.")?;
+ return self.get_nested_value(body_json, path);
+ }
+ }
+
+ None
+ }
+
+ /// Get a nested value from JSON
+ fn get_nested_value<'b>(&self, json: &'b serde_json::Value, path: &str) -> Option<&'b serde_json::Value> {
+ let parts: Vec<&str> = path.split('.').collect();
+ let mut current = json;
+
+ for part in parts {
+ match current {
+ serde_json::Value::Object(map) => {
+ current = map.get(part)?;
+ }
+ serde_json::Value::Array(arr) => {
+ let idx: usize = part.parse().ok()?;
+ current = arr.get(idx)?;
+ }
+ _ => return None,
+ }
+ }
+
+ Some(current)
+ }
+
+ /// Check equals assertion
+ fn check_equals(&self, field: &str, expected: &serde_json::Value) -> bool {
+ match self.get_value(field) {
+ Some(actual) => actual == expected,
+ None => false,
+ }
+ }
+
+ /// Check contains assertion
+ fn check_contains(&self, field: &str, expected: &str) -> bool {
+ match self.get_value(field) {
+ Some(serde_json::Value::String(s)) => s.contains(expected),
+ Some(v) => v.to_string().contains(expected),
+ None => false,
+ }
+ }
+
+ /// Check regexp assertion
+ fn check_regexp(&self, field: &str, pattern: &str) -> bool {
+ let regex = match Regex::new(pattern) {
+ Ok(r) => r,
+ Err(_) => return false,
+ };
+
+ match self.get_value(field) {
+ Some(serde_json::Value::String(s)) => regex.is_match(s),
+ Some(v) => regex.is_match(&v.to_string()),
+ None => false,
+ }
+ }
+
+ /// Check range assertion
+ fn check_range(&self, field: &str, range: &crate::config::types::RangeCondition) -> bool {
+ let value = match self.get_value(field) {
+ Some(serde_json::Value::Number(n)) => n.as_f64(),
+ _ => None,
+ };
+
+ let value = match value {
+ Some(v) => v,
+ None => return false,
+ };
+
+ if let Some(gte) = range.gte {
+ if value < gte {
+ return false;
+ }
+ }
+
+ if let Some(gt) = range.gt {
+ if value <= gt {
+ return false;
+ }
+ }
+
+ if let Some(lte) = range.lte {
+ if value > lte {
+ return false;
+ }
+ }
+
+ if let Some(lt) = range.lt {
+ if value >= lt {
+ return false;
+ }
+ }
+
+ true
+ }
+
+ /// Check in assertion
+ fn check_in(&self, field: &str, values: &[serde_json::Value]) -> bool {
+ match self.get_value(field) {
+ Some(actual) => values.contains(actual),
+ None => false,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn make_ctx(pairs: Vec<(&str, serde_json::Value)>) -> HashMap {
+ pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect()
+ }
+
+ #[test]
+ fn test_check_equals() {
+ let ctx = make_ctx(vec![
+ ("_ctx.response.status", serde_json::json!(200)),
+ ("_ctx.response.body", serde_json::json!("hello")),
+ ]);
+
+ let checker = AssertionChecker::new(&ctx);
+
+ assert!(checker.check_equals("_ctx.response.status", &serde_json::json!(200)));
+ assert!(!checker.check_equals("_ctx.response.status", &serde_json::json!(201)));
+ assert!(checker.check_equals("_ctx.response.body", &serde_json::json!("hello")));
+ }
+
+ #[test]
+ fn test_check_contains() {
+ let ctx = make_ctx(vec![
+ ("_ctx.response.body", serde_json::json!("hello world")),
+ ]);
+
+ let checker = AssertionChecker::new(&ctx);
+
+ assert!(checker.check_contains("_ctx.response.body", "world"));
+ assert!(!checker.check_contains("_ctx.response.body", "foo"));
+ }
+
+ #[test]
+ fn test_check_regexp() {
+ let ctx = make_ctx(vec![
+ ("_ctx.response.body", serde_json::json!("hello123world")),
+ ]);
+
+ let checker = AssertionChecker::new(&ctx);
+
+ assert!(checker.check_regexp("_ctx.response.body", r"\d+"));
+ assert!(!checker.check_regexp("_ctx.response.body", r"^\d+$"));
+ }
+
+ #[test]
+ fn test_check_range() {
+ let ctx = make_ctx(vec![
+ ("_ctx.elapsed", serde_json::json!(150)),
+ ]);
+
+ let checker = AssertionChecker::new(&ctx);
+
+ let range = crate::config::types::RangeCondition {
+ gte: Some(100.0),
+ lte: Some(200.0),
+ ..Default::default()
+ };
+
+ assert!(checker.check_range("_ctx.elapsed", &range));
+
+ let range2 = crate::config::types::RangeCondition {
+ gt: Some(150.0),
+ ..Default::default()
+ };
+
+ assert!(!checker.check_range("_ctx.elapsed", &range2));
+ }
+
+ #[test]
+ fn test_check_in() {
+ let ctx = make_ctx(vec![
+ ("_ctx.response.status", serde_json::json!(200)),
+ ]);
+
+ let checker = AssertionChecker::new(&ctx);
+
+ assert!(checker.check_in(
+ "_ctx.response.status",
+ &[serde_json::json!(200), serde_json::json!(201)]
+ ));
+ assert!(!checker.check_in(
+ "_ctx.response.status",
+ &[serde_json::json!(400), serde_json::json!(500)]
+ ));
+ }
+
+ #[test]
+ fn test_check_config() {
+ let ctx = make_ctx(vec![
+ ("_ctx.response.status", serde_json::json!(200)),
+ ("_ctx.response.body", serde_json::json!("success")),
+ ]);
+
+ let checker = AssertionChecker::new(&ctx);
+
+ let mut config = AssertConfig::default();
+ config.equals.insert("_ctx.response.status".to_string(), serde_json::json!(200));
+ config.contains.insert("_ctx.response.body".to_string(), "success".to_string());
+
+ assert!(checker.check(&config));
+
+ config.equals.insert("_ctx.response.status".to_string(), serde_json::json!(201));
+ assert!(!checker.check(&config));
+ }
+}
diff --git a/loadgen-rust/src/assertion/mod.rs b/loadgen-rust/src/assertion/mod.rs
new file mode 100644
index 0000000..19812eb
--- /dev/null
+++ b/loadgen-rust/src/assertion/mod.rs
@@ -0,0 +1,26 @@
+// Copyright (C) INFINI Labs & INFINI LIMITED.
+//
+// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0
+// and as commercial software.
+//
+// For commercial licensing, contact us at:
+// - Website: infinilabs.com
+// - Email: hello@infini.ltd
+//
+// Open Source licensed under AGPL V3:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Assertion module for loadgen
+
+pub mod conditions;
diff --git a/loadgen-rust/src/config/dsl.rs b/loadgen-rust/src/config/dsl.rs
new file mode 100644
index 0000000..ad57225
--- /dev/null
+++ b/loadgen-rust/src/config/dsl.rs
@@ -0,0 +1,328 @@
+// Copyright (C) INFINI Labs & INFINI LIMITED.
+//
+// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0
+// and as commercial software.
+//
+// For commercial licensing, contact us at:
+// - Website: infinilabs.com
+// - Email: hello@infini.ltd
+//
+// Open Source licensed under AGPL V3:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! DSL parser for loadgen
+//!
+//! The DSL format supports:
+//! - Comments starting with #
+//! - JSON configuration in comments (# runner: {...}, # variables: [...])
+//! - HTTP method and path on a single line (GET /path, POST /path)
+//! - Request body on following lines until next HTTP method or comment
+//! - Expected status code comment (# 200)
+//! - Request configuration comment (# request: {...})
+
+use anyhow::{Context, Result};
+use regex::Regex;
+use std::path::Path;
+use tracing::debug;
+
+use super::types::{AppConfig, LoaderConfig, Request, RequestItem, RunnerConfig, Variable};
+
+/// Parse a DSL file and return a LoaderConfig
+pub async fn parse_dsl_file(path: &Path, app_config: &AppConfig) -> Result {
+ let contents = tokio::fs::read_to_string(path)
+ .await
+ .with_context(|| format!("Failed to read DSL file: {:?}", path))?;
+
+ parse_dsl(&contents, app_config)
+}
+
+/// Parse DSL content and return a LoaderConfig
+pub fn parse_dsl(content: &str, app_config: &AppConfig) -> Result {
+ let mut config = LoaderConfig {
+ runner: app_config.runner.clone(),
+ variables: app_config.variables.clone(),
+ requests: Vec::new(),
+ };
+
+ // Regex patterns
+ let http_method_re = Regex::new(r"^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(.+)$").unwrap();
+ let json_comment_re = Regex::new(r"^#\s*(runner|variables|request):\s*(.+)$").unwrap();
+ let status_comment_re = Regex::new(r"^#\s*(\d{3})\s*$").unwrap();
+
+ let mut current_request: Option = None;
+ let mut current_body = String::new();
+ let mut in_body = false;
+ let mut pending_json_config: Option<(String, String)> = None;
+ let mut multiline_json = String::new();
+ let mut in_multiline_json = false;
+ let mut json_brace_count = 0;
+ let mut json_bracket_count = 0;
+ let mut json_type = String::new();
+
+ for line in content.lines() {
+ let trimmed = line.trim();
+
+ // Handle multiline JSON in comments
+ if in_multiline_json {
+ // Continue collecting JSON
+ if trimmed.starts_with('#') {
+ let json_part = trimmed.trim_start_matches('#').trim();
+ multiline_json.push_str(json_part);
+ multiline_json.push('\n');
+
+ // Count braces/brackets
+ for c in json_part.chars() {
+ match c {
+ '{' => json_brace_count += 1,
+ '}' => json_brace_count -= 1,
+ '[' => json_bracket_count += 1,
+ ']' => json_bracket_count -= 1,
+ _ => {}
+ }
+ }
+
+ // Check if JSON is complete
+ if json_brace_count == 0 && json_bracket_count == 0 {
+ in_multiline_json = false;
+ pending_json_config = Some((json_type.clone(), multiline_json.clone()));
+ multiline_json.clear();
+ }
+ } else {
+ // Not a comment, JSON is malformed
+ in_multiline_json = false;
+ multiline_json.clear();
+ }
+ continue;
+ }
+
+ // Check for JSON configuration comments
+ if let Some(caps) = json_comment_re.captures(trimmed) {
+ let config_type = caps.get(1).unwrap().as_str();
+ let json_str = caps.get(2).unwrap().as_str();
+
+ // Count opening braces/brackets
+ json_brace_count = 0;
+ json_bracket_count = 0;
+ for c in json_str.chars() {
+ match c {
+ '{' => json_brace_count += 1,
+ '}' => json_brace_count -= 1,
+ '[' => json_bracket_count += 1,
+ ']' => json_bracket_count -= 1,
+ _ => {}
+ }
+ }
+
+ if json_brace_count == 0 && json_bracket_count == 0 {
+ // Complete JSON on one line
+ pending_json_config = Some((config_type.to_string(), json_str.to_string()));
+ } else {
+ // Multiline JSON
+ in_multiline_json = true;
+ json_type = config_type.to_string();
+ multiline_json = json_str.to_string();
+ multiline_json.push('\n');
+ }
+ continue;
+ }
+
+ // Process pending JSON config
+ if let Some((config_type, json_str)) = pending_json_config.take() {
+ match config_type.as_str() {
+ "runner" => {
+ if let Ok(runner) = serde_json::from_str::(&json_str) {
+ config.runner = runner;
+ }
+ }
+ "variables" => {
+ if let Ok(vars) = serde_json::from_str::>(&json_str) {
+ config.variables.extend(vars);
+ }
+ }
+ "request" => {
+ if let Some(ref mut req) = current_request {
+ if let Ok(request) = serde_json::from_str::(&json_str) {
+ if let Some(ref mut existing) = req.request {
+ // Merge request config
+ if !request.runtime_variables.is_empty() {
+ existing.runtime_variables = request.runtime_variables;
+ }
+ if !request.runtime_body_line_variables.is_empty() {
+ existing.runtime_body_line_variables = request.runtime_body_line_variables;
+ }
+ if request.repeat_body_n_times > 0 {
+ existing.repeat_body_n_times = request.repeat_body_n_times;
+ }
+ if request.basic_auth.is_some() {
+ existing.basic_auth = request.basic_auth;
+ }
+ }
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+
+ // Skip empty lines
+ if trimmed.is_empty() {
+ if in_body {
+ current_body.push('\n');
+ }
+ continue;
+ }
+
+ // Skip regular comments (not JSON config)
+ if trimmed.starts_with('#') {
+ // Check for status code comment
+ if let Some(caps) = status_comment_re.captures(trimmed) {
+ // This is an expected status code, could be used for assertions
+ let _status: i32 = caps.get(1).unwrap().as_str().parse().unwrap_or(200);
+ // We don't do anything with status code comments for now
+ }
+
+ // Check for // style comments (DSL comments)
+ if trimmed.starts_with("# //") || trimmed.starts_with("#//") {
+ continue;
+ }
+
+ // End body collection on regular comment
+ if in_body && !current_body.is_empty() {
+ if let Some(ref mut req) = current_request {
+ if let Some(ref mut request) = req.request {
+ request.body = current_body.trim_end().to_string();
+ }
+ }
+ current_body.clear();
+ in_body = false;
+ }
+ continue;
+ }
+
+ // Check for HTTP method line
+ if let Some(caps) = http_method_re.captures(trimmed) {
+ // Save previous request
+ if let Some(mut req) = current_request.take() {
+ if in_body && !current_body.is_empty() {
+ if let Some(ref mut request) = req.request {
+ request.body = current_body.trim_end().to_string();
+ }
+ }
+ config.requests.push(req);
+ }
+ current_body.clear();
+
+ let method = caps.get(1).unwrap().as_str();
+ let path = caps.get(2).unwrap().as_str();
+
+ current_request = Some(RequestItem {
+ request: Some(Request {
+ method: method.to_string(),
+ url: path.to_string(),
+ ..Default::default()
+ }),
+ ..Default::default()
+ });
+ in_body = true;
+ continue;
+ }
+
+ // Collect body lines
+ if in_body {
+ if !current_body.is_empty() {
+ current_body.push('\n');
+ }
+ current_body.push_str(line);
+ }
+ }
+
+ // Save last request
+ if let Some(mut req) = current_request.take() {
+ if in_body && !current_body.is_empty() {
+ if let Some(ref mut request) = req.request {
+ request.body = current_body.trim_end().to_string();
+ }
+ }
+ config.requests.push(req);
+ }
+
+ debug!("Parsed {} requests from DSL", config.requests.len());
+ Ok(config)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_parse_simple_dsl() {
+ let dsl = r#"
+DELETE /medcl
+
+PUT /medcl
+
+POST /medcl/_doc/1
+{
+ "name": "medcl"
+}
+
+GET /medcl/_search
+"#;
+
+ let app_config = AppConfig::default();
+ let config = parse_dsl(dsl, &app_config).unwrap();
+
+ assert_eq!(config.requests.len(), 4);
+
+ let req0 = config.requests[0].request.as_ref().unwrap();
+ assert_eq!(req0.method, "DELETE");
+ assert_eq!(req0.url, "/medcl");
+
+ let req2 = config.requests[2].request.as_ref().unwrap();
+ assert_eq!(req2.method, "POST");
+ assert_eq!(req2.url, "/medcl/_doc/1");
+ assert!(req2.body.contains("medcl"));
+ }
+
+ #[test]
+ fn test_parse_dsl_with_runner_config() {
+ let dsl = r#"
+# runner: {"total_rounds": 5, "no_warm": true}
+
+GET /test
+"#;
+
+ let app_config = AppConfig::default();
+ let config = parse_dsl(dsl, &app_config).unwrap();
+
+ assert_eq!(config.runner.total_rounds, 5);
+ assert!(config.runner.no_warm);
+ }
+
+ #[test]
+ fn test_parse_dsl_with_variables() {
+ let dsl = r#"
+# variables: [{"name": "id", "type": "sequence"}]
+
+POST /test/$[[id]]
+"#;
+
+ let app_config = AppConfig::default();
+ let config = parse_dsl(dsl, &app_config).unwrap();
+
+ assert_eq!(config.variables.len(), 1);
+ assert_eq!(config.variables[0].name, "id");
+ assert_eq!(config.variables[0].var_type, "sequence");
+ }
+}
diff --git a/loadgen-rust/src/config/mod.rs b/loadgen-rust/src/config/mod.rs
new file mode 100644
index 0000000..ae344e5
--- /dev/null
+++ b/loadgen-rust/src/config/mod.rs
@@ -0,0 +1,28 @@
+// Copyright (C) INFINI Labs & INFINI LIMITED.
+//
+// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0
+// and as commercial software.
+//
+// For commercial licensing, contact us at:
+// - Website: infinilabs.com
+// - Email: hello@infini.ltd
+//
+// Open Source licensed under AGPL V3:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Configuration module for loadgen
+
+pub mod dsl;
+pub mod types;
+pub mod yaml;
diff --git a/loadgen-rust/src/config/types.rs b/loadgen-rust/src/config/types.rs
new file mode 100644
index 0000000..c7c4394
--- /dev/null
+++ b/loadgen-rust/src/config/types.rs
@@ -0,0 +1,427 @@
+// Copyright (C) INFINI Labs & INFINI LIMITED.
+//
+// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0
+// and as commercial software.
+//
+// For commercial licensing, contact us at:
+// - Website: infinilabs.com
+// - Email: hello@infini.ltd
+//
+// Open Source licensed under AGPL V3:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Configuration type definitions matching the Go implementation
+
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+
+/// Basic authentication credentials
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct BasicAuth {
+ #[serde(default)]
+ pub username: String,
+ #[serde(default)]
+ pub password: String,
+}
+
+/// HTTP request definition
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct Request {
+ /// HTTP method (GET, POST, PUT, DELETE, etc.)
+ #[serde(default)]
+ pub method: String,
+
+ /// Request URL (can contain template variables)
+ #[serde(default)]
+ pub url: String,
+
+ /// Request body (can contain template variables)
+ #[serde(default)]
+ pub body: String,
+
+ /// Simple mode - no variable substitution
+ #[serde(default)]
+ pub simple_mode: bool,
+
+ /// Number of times to repeat the body in a single request
+ #[serde(default, alias = "body_repeat_times")]
+ pub repeat_body_n_times: i32,
+
+ /// HTTP headers
+ #[serde(default)]
+ pub headers: Vec>,
+
+ /// Basic authentication for this request
+ #[serde(default)]
+ pub basic_auth: Option,
+
+ /// Disable header names normalizing
+ #[serde(default)]
+ pub disable_header_names_normalizing: bool,
+
+ /// Runtime variables to set before executing the request
+ #[serde(default)]
+ pub runtime_variables: HashMap,
+
+ /// Runtime variables to set per body line
+ #[serde(default)]
+ pub runtime_body_line_variables: HashMap,
+
+ /// Number of times to execute this request
+ #[serde(default)]
+ pub execute_repeat_times: i32,
+}
+
+/// Variable definition
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct Variable {
+ /// Variable type (file, list, sequence, uuid, etc.)
+ #[serde(rename = "type")]
+ pub var_type: String,
+
+ /// Variable name
+ #[serde(default)]
+ pub name: String,
+
+ /// File path for file type variables
+ #[serde(default)]
+ pub path: String,
+
+ /// Inline data for list type variables
+ #[serde(default)]
+ pub data: Vec,
+
+ /// Date format for now_with_format type
+ #[serde(default)]
+ pub format: String,
+
+ /// Range start for range/sequence types
+ #[serde(default)]
+ pub from: u64,
+
+ /// Range end for range/sequence types
+ #[serde(default)]
+ pub to: u64,
+
+ /// Character replacement map
+ #[serde(default)]
+ pub replace: HashMap,
+
+ /// Size for random_array type
+ #[serde(default)]
+ pub size: usize,
+
+ /// Variable key for random_array type
+ #[serde(default)]
+ pub variable_key: String,
+
+ /// Variable type for random_array (number/string)
+ #[serde(default)]
+ pub variable_type: String,
+
+ /// Whether to include square brackets for random_array
+ #[serde(default)]
+ pub square_bracket: bool,
+
+ /// String bracket character for random_array
+ #[serde(default)]
+ pub string_bracket: String,
+}
+
+/// Assertion condition configuration
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct AssertConfig {
+ /// Equals assertion: field -> expected value
+ #[serde(default)]
+ pub equals: HashMap,
+
+ /// Not equals assertion
+ #[serde(default)]
+ pub not_equals: HashMap,
+
+ /// Contains assertion
+ #[serde(default)]
+ pub contains: HashMap,
+
+ /// Not contains assertion
+ #[serde(default)]
+ pub not_contains: HashMap,
+
+ /// Regex match assertion
+ #[serde(default)]
+ pub regexp: HashMap,
+
+ /// Regex not match assertion
+ #[serde(default)]
+ pub not_regexp: HashMap,
+
+ /// Range assertion
+ #[serde(default)]
+ pub range: HashMap,
+
+ /// In list assertion
+ #[serde(default, rename = "in")]
+ pub in_list: HashMap>,
+
+ /// Not in list assertion
+ #[serde(default)]
+ pub not_in: HashMap>,
+}
+
+/// Range condition for assertions
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct RangeCondition {
+ #[serde(default)]
+ pub gte: Option,
+ #[serde(default)]
+ pub gt: Option,
+ #[serde(default)]
+ pub lte: Option,
+ #[serde(default)]
+ pub lt: Option,
+}
+
+/// Sleep action between requests
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct SleepAction {
+ #[serde(default)]
+ pub sleep_in_milli_seconds: i64,
+}
+
+/// Request item with optional assertions
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct RequestItem {
+ /// The HTTP request
+ #[serde(default)]
+ pub request: Option,
+
+ /// Assertion conditions
+ #[serde(default)]
+ pub assert: Option,
+
+ /// DSL-based assertion
+ #[serde(default)]
+ pub assert_dsl: String,
+
+ /// Sleep action after request
+ #[serde(default)]
+ pub sleep: Option,
+
+ /// Register response values to global context
+ #[serde(default)]
+ pub register: Vec>,
+}
+
+/// Runner configuration
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RunnerConfig {
+ /// How many rounds of requests to run
+ #[serde(default)]
+ pub total_rounds: i64,
+
+ /// Skip warmup round
+ #[serde(default)]
+ pub no_warm: bool,
+
+ /// Valid status codes during warmup
+ #[serde(default)]
+ pub valid_status_codes_during_warmup: Vec,
+
+ /// Exit(1) if any assertion failed
+ #[serde(default)]
+ pub assert_invalid: bool,
+
+ /// Continue running on assertion failure
+ #[serde(default)]
+ pub continue_on_assert_invalid: bool,
+
+ /// Skip invalid assertions
+ #[serde(default)]
+ pub skip_invalid_assert: bool,
+
+ /// Exit(2) if any error occurred
+ #[serde(default)]
+ pub assert_error: bool,
+
+ /// Log all requests
+ #[serde(default)]
+ pub log_requests: bool,
+
+ /// Benchmark only mode
+ #[serde(default)]
+ pub benchmark_only: bool,
+
+ /// Use microseconds for duration
+ #[serde(default)]
+ pub duration_in_us: bool,
+
+ /// Disable stats collection
+ #[serde(default)]
+ pub no_stats: bool,
+
+ /// Disable size stats
+ #[serde(default)]
+ pub no_size_stats: bool,
+
+ /// Metric sample size for histogram
+ #[serde(default = "default_metric_sample_size")]
+ pub metric_sample_size: usize,
+
+ /// Log requests with these status codes
+ #[serde(default)]
+ pub log_status_codes: Vec,
+
+ /// Disable header name normalizing
+ #[serde(default)]
+ pub disable_header_names_normalizing: bool,
+
+ /// Reset context before test run
+ #[serde(default)]
+ pub reset_context: bool,
+
+ /// Default endpoint for requests
+ #[serde(default)]
+ pub default_endpoint: String,
+
+ /// Default basic auth for requests
+ #[serde(default)]
+ pub default_basic_auth: Option,
+}
+
+fn default_metric_sample_size() -> usize {
+ 10000
+}
+
+impl Default for RunnerConfig {
+ fn default() -> Self {
+ Self {
+ total_rounds: -1,
+ no_warm: false,
+ valid_status_codes_during_warmup: vec![],
+ assert_invalid: false,
+ continue_on_assert_invalid: false,
+ skip_invalid_assert: false,
+ assert_error: false,
+ log_requests: false,
+ benchmark_only: false,
+ duration_in_us: false,
+ no_stats: false,
+ no_size_stats: false,
+ metric_sample_size: 10000,
+ log_status_codes: vec![],
+ disable_header_names_normalizing: false,
+ reset_context: false,
+ default_endpoint: String::new(),
+ default_basic_auth: None,
+ }
+ }
+}
+
+/// Test case configuration
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct Test {
+ /// Directory path containing test configurations
+ #[serde(default)]
+ pub path: String,
+
+ /// Whether to use compression
+ #[serde(default)]
+ pub compress: bool,
+}
+
+/// Loader configuration
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct LoaderConfig {
+ /// Variable definitions
+ #[serde(default)]
+ pub variables: Vec,
+
+ /// Request definitions
+ #[serde(default)]
+ pub requests: Vec,
+
+ /// Runner configuration
+ #[serde(default)]
+ pub runner: RunnerConfig,
+}
+
+/// Application configuration
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct AppConfig {
+ /// Environment variables
+ #[serde(default, alias = "env")]
+ pub environments: HashMap,
+
+ /// Test cases
+ #[serde(default)]
+ pub tests: Vec,
+
+ /// Variable definitions
+ #[serde(default)]
+ pub variables: Vec,
+
+ /// Request definitions
+ #[serde(default)]
+ pub requests: Vec,
+
+ /// Runner configuration
+ #[serde(default)]
+ pub runner: RunnerConfig,
+}
+
+impl AppConfig {
+ /// Convert to LoaderConfig
+ pub fn to_loader_config(&self) -> LoaderConfig {
+ LoaderConfig {
+ variables: self.variables.clone(),
+ requests: self.requests.clone(),
+ runner: self.runner.clone(),
+ }
+ }
+
+ /// Check if environment variables exist
+ pub fn test_env(&self, env_vars: &[&str]) -> bool {
+ for env_var in env_vars {
+ match self.environments.get(*env_var) {
+ Some(v) if !v.is_empty() => continue,
+ _ => return false,
+ }
+ }
+ true
+ }
+}
+
+/// Request result for statistics
+#[derive(Debug, Clone, Default)]
+pub struct RequestResult {
+ pub request_count: i32,
+ pub request_size: i64,
+ pub response_size: i64,
+ pub status: i32,
+ pub error: bool,
+ pub invalid: bool,
+ pub duration: std::time::Duration,
+}
+
+impl RequestResult {
+ pub fn reset(&mut self) {
+ self.request_count = 0;
+ self.request_size = 0;
+ self.response_size = 0;
+ self.status = 0;
+ self.error = false;
+ self.invalid = false;
+ self.duration = std::time::Duration::ZERO;
+ }
+}
diff --git a/loadgen-rust/src/config/yaml.rs b/loadgen-rust/src/config/yaml.rs
new file mode 100644
index 0000000..816c3a5
--- /dev/null
+++ b/loadgen-rust/src/config/yaml.rs
@@ -0,0 +1,142 @@
+// Copyright (C) INFINI Labs & INFINI LIMITED.
+//
+// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0
+// and as commercial software.
+//
+// For commercial licensing, contact us at:
+// - Website: infinilabs.com
+// - Email: hello@infini.ltd
+//
+// Open Source licensed under AGPL V3:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! YAML configuration loader
+
+use anyhow::{Context, Result};
+use std::path::Path;
+use tracing::debug;
+
+use super::types::AppConfig;
+
+/// Load configuration from a YAML file
+pub async fn load_config(path: &Path) -> Result {
+ debug!("Loading config from {:?}", path);
+
+ // Check if file exists
+ if !path.exists() {
+ // Return default config if file doesn't exist
+ debug!("Config file not found, using defaults");
+ return Ok(AppConfig::default());
+ }
+
+ // Read file contents
+ let contents = tokio::fs::read_to_string(path)
+ .await
+ .with_context(|| format!("Failed to read config file: {:?}", path))?;
+
+ // Parse YAML
+ let mut config: AppConfig = serde_yaml::from_str(&contents)
+ .with_context(|| format!("Failed to parse config file: {:?}", path))?;
+
+ // Merge system environment variables
+ for (key, value) in std::env::vars() {
+ if config.environments.contains_key(&key) {
+ config.environments.insert(key, value);
+ }
+ }
+
+ debug!("Loaded config with {} requests", config.requests.len());
+ Ok(config)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io::Write;
+ use tempfile::NamedTempFile;
+
+ #[tokio::test]
+ async fn test_load_config_not_found() {
+ let result = load_config(Path::new("nonexistent.yml")).await;
+ assert!(result.is_ok());
+ }
+
+ #[tokio::test]
+ async fn test_load_config_basic() {
+ let mut file = NamedTempFile::new().unwrap();
+ writeln!(
+ file,
+ r#"
+env:
+ TEST_VAR: test_value
+
+runner:
+ no_warm: true
+ total_rounds: 5
+
+variables:
+ - name: test_id
+ type: sequence
+
+requests:
+ - request:
+ method: GET
+ url: http://localhost:8080/test
+"#
+ )
+ .unwrap();
+
+ let result = load_config(file.path()).await;
+ assert!(result.is_ok());
+
+ let config = result.unwrap();
+ assert_eq!(config.environments.get("TEST_VAR"), Some(&"test_value".to_string()));
+ assert!(config.runner.no_warm);
+ assert_eq!(config.runner.total_rounds, 5);
+ assert_eq!(config.variables.len(), 1);
+ assert_eq!(config.requests.len(), 1);
+ }
+
+ #[tokio::test]
+ async fn test_load_config_with_basic_auth() {
+ let mut file = NamedTempFile::new().unwrap();
+ writeln!(
+ file,
+ r#"
+runner:
+ default_basic_auth:
+ username: admin
+ password: secret
+
+requests:
+ - request:
+ method: POST
+ url: http://localhost:8080/api
+ basic_auth:
+ username: user
+ password: pass
+"#
+ )
+ .unwrap();
+
+ let result = load_config(file.path()).await;
+ assert!(result.is_ok());
+
+ let config = result.unwrap();
+ assert!(config.runner.default_basic_auth.is_some());
+ let auth = config.runner.default_basic_auth.unwrap();
+ assert_eq!(auth.username, "admin");
+ assert_eq!(auth.password, "secret");
+ }
+}
diff --git a/loadgen-rust/src/http/client.rs b/loadgen-rust/src/http/client.rs
new file mode 100644
index 0000000..c26277e
--- /dev/null
+++ b/loadgen-rust/src/http/client.rs
@@ -0,0 +1,158 @@
+// Copyright (C) INFINI Labs & INFINI LIMITED.
+//
+// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0
+// and as commercial software.
+//
+// For commercial licensing, contact us at:
+// - Website: infinilabs.com
+// - Email: hello@infini.ltd
+//
+// Open Source licensed under AGPL V3:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! HTTP client wrapper for loadgen
+
+use std::time::Duration;
+
+use anyhow::Result;
+use reqwest::{Client, Response};
+use tracing::debug;
+
+/// HTTP client configuration
+#[derive(Debug, Clone)]
+pub struct HttpClientConfig {
+ /// Request timeout in seconds (0 = no timeout)
+ pub timeout: u64,
+ /// Read timeout in seconds
+ pub read_timeout: u64,
+ /// Write timeout in seconds
+ pub write_timeout: u64,
+ /// Dial/connect timeout in seconds
+ pub dial_timeout: u64,
+ /// Enable gzip compression
+ pub compress: bool,
+ /// Disable header name normalization
+ pub disable_header_normalizing: bool,
+ /// Maximum connections per host
+ pub max_connections_per_host: usize,
+ /// User agent string
+ pub user_agent: String,
+}
+
+impl Default for HttpClientConfig {
+ fn default() -> Self {
+ Self {
+ timeout: 0,
+ read_timeout: 0,
+ write_timeout: 0,
+ dial_timeout: 3,
+ compress: false,
+ disable_header_normalizing: false,
+ max_connections_per_host: 100,
+ user_agent: format!("loadgen-rust/{}", env!("CARGO_PKG_VERSION")),
+ }
+ }
+}
+
+/// HTTP client wrapper
+pub struct HttpClient {
+ client: Client,
+ config: HttpClientConfig,
+}
+
+impl HttpClient {
+ /// Create a new HTTP client
+ pub fn new(config: HttpClientConfig) -> Result {
+ let mut builder = Client::builder()
+ .user_agent(&config.user_agent)
+ .pool_max_idle_per_host(config.max_connections_per_host)
+ .danger_accept_invalid_certs(true) // Match Go's InsecureSkipVerify
+ .use_rustls_tls();
+
+ // Set connect timeout
+ if config.dial_timeout > 0 {
+ builder = builder.connect_timeout(Duration::from_secs(config.dial_timeout));
+ }
+
+ // Set overall timeout
+ let effective_timeout = if config.timeout > 0 {
+ config.timeout
+ } else if config.read_timeout > 0 {
+ config.read_timeout
+ } else {
+ 0
+ };
+
+ if effective_timeout > 0 {
+ builder = builder.timeout(Duration::from_secs(effective_timeout));
+ }
+
+ // Enable gzip
+ if config.compress {
+ builder = builder.gzip(true);
+ }
+
+ let client = builder.build()?;
+ debug!("HTTP client created with config: {:?}", config);
+
+ Ok(Self { client, config })
+ }
+
+ /// Get the internal reqwest client
+ pub fn client(&self) -> &Client {
+ &self.client
+ }
+
+ /// Get the client configuration
+ pub fn config(&self) -> &HttpClientConfig {
+ &self.config
+ }
+
+ /// Execute a request and return the response
+ pub async fn execute(&self, request: reqwest::Request) -> Result {
+ let response = self.client.execute(request).await?;
+ Ok(response)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_default_config() {
+ let config = HttpClientConfig::default();
+ assert_eq!(config.timeout, 0);
+ assert_eq!(config.dial_timeout, 3);
+ assert!(!config.compress);
+ }
+
+ #[tokio::test]
+ async fn test_create_client() {
+ let config = HttpClientConfig::default();
+ let client = HttpClient::new(config);
+ assert!(client.is_ok());
+ }
+
+ #[tokio::test]
+ async fn test_create_client_with_timeout() {
+ let config = HttpClientConfig {
+ timeout: 30,
+ dial_timeout: 5,
+ ..Default::default()
+ };
+ let client = HttpClient::new(config);
+ assert!(client.is_ok());
+ }
+}
diff --git a/loadgen-rust/src/http/mod.rs b/loadgen-rust/src/http/mod.rs
new file mode 100644
index 0000000..35689d3
--- /dev/null
+++ b/loadgen-rust/src/http/mod.rs
@@ -0,0 +1,29 @@
+// Copyright (C) INFINI Labs & INFINI LIMITED.
+//
+// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0
+// and as commercial software.
+//
+// For commercial licensing, contact us at:
+// - Website: infinilabs.com
+// - Email: hello@infini.ltd
+//
+// Open Source licensed under AGPL V3:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! HTTP module for loadgen
+
+pub mod client;
+pub mod request;
+
+pub use client::HttpClient;
diff --git a/loadgen-rust/src/http/request.rs b/loadgen-rust/src/http/request.rs
new file mode 100644
index 0000000..d800c57
--- /dev/null
+++ b/loadgen-rust/src/http/request.rs
@@ -0,0 +1,314 @@
+// Copyright (C) INFINI Labs & INFINI LIMITED.
+//
+// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0
+// and as commercial software.
+//
+// For commercial licensing, contact us at:
+// - Website: infinilabs.com
+// - Email: hello@infini.ltd
+//
+// Open Source licensed under AGPL V3:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! HTTP request building for loadgen
+
+use std::collections::HashMap;
+use std::io::Write;
+
+use anyhow::{Context, Result};
+use flate2::write::GzEncoder;
+use flate2::Compression;
+use reqwest::header::{HeaderMap, HeaderName, HeaderValue, ACCEPT_ENCODING, CONTENT_ENCODING};
+use reqwest::{Body, Method, Url};
+use tracing::debug;
+
+use crate::config::types::{Request, RequestItem, RunnerConfig};
+use crate::template::TemplateEngine;
+use crate::variable::provider::VariableProvider;
+
+/// Request builder for loadgen
+pub struct RequestBuilder<'a> {
+ provider: &'a VariableProvider,
+ runner_config: &'a RunnerConfig,
+ compress: bool,
+}
+
+impl<'a> RequestBuilder<'a> {
+ /// Create a new request builder
+ pub fn new(
+ provider: &'a VariableProvider,
+ runner_config: &'a RunnerConfig,
+ compress: bool,
+ ) -> Self {
+ Self {
+ provider,
+ runner_config,
+ compress,
+ }
+ }
+
+ /// Build a reqwest Request from a RequestItem
+ pub fn build(
+ &self,
+ item: &RequestItem,
+ runtime_vars: &mut HashMap,
+ client: &reqwest::Client,
+ ) -> Result {
+ let request = item.request.as_ref().context("Request is None")?;
+
+ // Process runtime variables
+ for (key, var_name) in &request.runtime_variables {
+ let value = self.provider.get_value(var_name, Some(runtime_vars));
+ runtime_vars.insert(key.clone(), value);
+ }
+
+ // Build URL
+ let url = self.build_url(request, runtime_vars)?;
+ debug!("Request URL: {}", url);
+
+ // Get method
+ let method = self.parse_method(&request.method)?;
+
+ // Start building request
+ let mut builder = client.request(method, url);
+
+ // Set basic auth
+ builder = self.set_basic_auth(builder, request);
+
+ // Build headers
+ builder = self.set_headers(builder, request, runtime_vars)?;
+
+ // Build body
+ if !request.body.is_empty() {
+ let body = self.build_body(request, runtime_vars)?;
+
+ if self.compress && !body.is_empty() {
+ // Compress body
+ let mut encoder = GzEncoder::new(Vec::new(), Compression::best());
+ encoder.write_all(body.as_bytes())?;
+ let compressed = encoder.finish()?;
+
+ builder = builder
+ .header(CONTENT_ENCODING, "gzip")
+ .header(ACCEPT_ENCODING, "gzip")
+ .header("X-PayLoad-Compressed", "true")
+ .body(Body::from(compressed));
+ } else {
+ builder = builder.body(Body::from(body.clone()));
+ }
+
+ builder = builder.header("X-PayLoad-Size", body.len().to_string());
+ }
+
+ builder.build().context("Failed to build request")
+ }
+
+ /// Build the URL with template substitution
+ fn build_url(
+ &self,
+ request: &Request,
+ runtime_vars: &HashMap,
+ ) -> Result {
+ let url_str = if TemplateEngine::has_template(&request.url) {
+ TemplateEngine::execute(&request.url, |var| {
+ self.provider.get_value(var, Some(runtime_vars))
+ })
+ } else {
+ request.url.clone()
+ };
+
+ // If URL doesn't have a host, use default endpoint
+ if url_str.starts_with('/') {
+ if self.runner_config.default_endpoint.is_empty() {
+ anyhow::bail!("URL is relative but no default_endpoint configured: {}", url_str);
+ }
+ let full_url = format!("{}{}", self.runner_config.default_endpoint.trim_end_matches('/'), url_str);
+ Url::parse(&full_url).context("Failed to parse URL with default endpoint")
+ } else {
+ Url::parse(&url_str).context("Failed to parse URL")
+ }
+ }
+
+ /// Parse HTTP method
+ fn parse_method(&self, method: &str) -> Result {
+ match method.to_uppercase().as_str() {
+ "GET" => Ok(Method::GET),
+ "POST" => Ok(Method::POST),
+ "PUT" => Ok(Method::PUT),
+ "DELETE" => Ok(Method::DELETE),
+ "PATCH" => Ok(Method::PATCH),
+ "HEAD" => Ok(Method::HEAD),
+ "OPTIONS" => Ok(Method::OPTIONS),
+ _ => anyhow::bail!("Unsupported HTTP method: {}", method),
+ }
+ }
+
+ /// Set basic auth on request
+ fn set_basic_auth(
+ &self,
+ mut builder: reqwest::RequestBuilder,
+ request: &Request,
+ ) -> reqwest::RequestBuilder {
+ // Check request-level auth first
+ if let Some(ref auth) = request.basic_auth {
+ if !auth.username.is_empty() {
+ builder = builder.basic_auth(&auth.username, Some(&auth.password));
+ return builder;
+ }
+ }
+
+ // Fall back to default auth
+ if let Some(ref auth) = self.runner_config.default_basic_auth {
+ if !auth.username.is_empty() {
+ builder = builder.basic_auth(&auth.username, Some(&auth.password));
+ }
+ }
+
+ builder
+ }
+
+ /// Set headers on request
+ fn set_headers(
+ &self,
+ mut builder: reqwest::RequestBuilder,
+ request: &Request,
+ runtime_vars: &HashMap,
+ ) -> Result {
+ let mut headers = HeaderMap::new();
+
+ for header_map in &request.headers {
+ for (key, value) in header_map {
+ let header_value = if TemplateEngine::has_template(value) {
+ TemplateEngine::execute(value, |var| {
+ self.provider.get_value(var, Some(runtime_vars))
+ })
+ } else {
+ value.clone()
+ };
+
+ let name = HeaderName::try_from(key.as_str())
+ .context(format!("Invalid header name: {}", key))?;
+ let value = HeaderValue::from_str(&header_value)
+ .context(format!("Invalid header value: {}", header_value))?;
+ headers.insert(name, value);
+ }
+ }
+
+ builder = builder.headers(headers);
+ Ok(builder)
+ }
+
+ /// Build request body with template substitution and body repetition
+ fn build_body(
+ &self,
+ request: &Request,
+ runtime_vars: &mut HashMap,
+ ) -> Result {
+ let repeat_times = if request.repeat_body_n_times > 0 {
+ request.repeat_body_n_times as usize
+ } else {
+ 1
+ };
+
+ let mut result = String::new();
+
+ for _ in 0..repeat_times {
+ // Process runtime body line variables for each repetition
+ for (key, var_name) in &request.runtime_body_line_variables {
+ let value = self.provider.get_value(var_name, Some(runtime_vars));
+ runtime_vars.insert(key.clone(), value);
+ }
+
+ let body = if TemplateEngine::has_template(&request.body) {
+ TemplateEngine::execute(&request.body, |var| {
+ self.provider.get_value(var, Some(runtime_vars))
+ })
+ } else {
+ request.body.clone()
+ };
+
+ result.push_str(&body);
+ }
+
+ Ok(result)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_parse_method() {
+ let provider = VariableProvider::new(&[], HashMap::new());
+ let runner_config = RunnerConfig::default();
+ let builder = RequestBuilder::new(&provider, &runner_config, false);
+
+ assert_eq!(builder.parse_method("GET").unwrap(), Method::GET);
+ assert_eq!(builder.parse_method("post").unwrap(), Method::POST);
+ assert_eq!(builder.parse_method("Put").unwrap(), Method::PUT);
+ assert!(builder.parse_method("INVALID").is_err());
+ }
+
+ #[test]
+ fn test_build_url_absolute() {
+ let provider = VariableProvider::new(&[], HashMap::new());
+ let runner_config = RunnerConfig::default();
+ let builder = RequestBuilder::new(&provider, &runner_config, false);
+
+ let request = Request {
+ url: "http://localhost:9200/test".to_string(),
+ ..Default::default()
+ };
+
+ let url = builder.build_url(&request, &HashMap::new()).unwrap();
+ assert_eq!(url.to_string(), "http://localhost:9200/test");
+ }
+
+ #[test]
+ fn test_build_url_relative() {
+ let provider = VariableProvider::new(&[], HashMap::new());
+ let runner_config = RunnerConfig {
+ default_endpoint: "http://localhost:9200".to_string(),
+ ..Default::default()
+ };
+ let builder = RequestBuilder::new(&provider, &runner_config, false);
+
+ let request = Request {
+ url: "/test/_search".to_string(),
+ ..Default::default()
+ };
+
+ let url = builder.build_url(&request, &HashMap::new()).unwrap();
+ assert_eq!(url.to_string(), "http://localhost:9200/test/_search");
+ }
+
+ #[test]
+ fn test_build_url_with_template() {
+ let mut env_vars = HashMap::new();
+ env_vars.insert("ES_ENDPOINT".to_string(), "http://localhost:9200".to_string());
+
+ let provider = VariableProvider::new(&[], env_vars);
+ let runner_config = RunnerConfig::default();
+ let builder = RequestBuilder::new(&provider, &runner_config, false);
+
+ let request = Request {
+ url: "$[[env.ES_ENDPOINT]]/test".to_string(),
+ ..Default::default()
+ };
+
+ let url = builder.build_url(&request, &HashMap::new()).unwrap();
+ assert_eq!(url.to_string(), "http://localhost:9200/test");
+ }
+}
diff --git a/loadgen-rust/src/lib.rs b/loadgen-rust/src/lib.rs
new file mode 100644
index 0000000..eda365e
--- /dev/null
+++ b/loadgen-rust/src/lib.rs
@@ -0,0 +1,39 @@
+// Copyright (C) INFINI Labs & INFINI LIMITED.
+//
+// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0
+// and as commercial software.
+//
+// For commercial licensing, contact us at:
+// - Website: infinilabs.com
+// - Email: hello@infini.ltd
+//
+// Open Source licensed under AGPL V3:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! INFINI Loadgen - A high-performance HTTP load generator and testing suite
+//!
+//! This is a Rust implementation of the loadgen tool, supporting the same
+//! YAML configuration and DSL formats as the original Go implementation.
+
+pub mod assertion;
+pub mod config;
+pub mod http;
+pub mod loader;
+pub mod runner;
+pub mod template;
+pub mod variable;
+
+pub use config::types::{AppConfig, LoaderConfig, RunnerConfig};
+pub use loader::generator::LoadGenerator;
+pub use loader::stats::LoadStats;
diff --git a/loadgen-rust/src/loader/generator.rs b/loadgen-rust/src/loader/generator.rs
new file mode 100644
index 0000000..b9ad4cb
--- /dev/null
+++ b/loadgen-rust/src/loader/generator.rs
@@ -0,0 +1,288 @@
+// Copyright (C) INFINI Labs & INFINI LIMITED.
+//
+// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0
+// and as commercial software.
+//
+// For commercial licensing, contact us at:
+// - Website: infinilabs.com
+// - Email: hello@infini.ltd
+//
+// Open Source licensed under AGPL V3:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Load generator - orchestrates workers and aggregates statistics
+
+use std::collections::HashMap;
+use std::sync::atomic::AtomicBool;
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use anyhow::Result;
+use tokio::task::JoinSet;
+use tracing::{error, info, warn};
+
+use super::stats::{AggregatedStats, LoadStats};
+use super::worker::{Worker, WorkerConfig};
+use crate::config::types::LoaderConfig;
+use crate::http::client::{HttpClient, HttpClientConfig};
+use crate::http::request::RequestBuilder;
+use crate::variable::provider::VariableProvider;
+
+/// CLI options passed to the generator
+#[derive(Debug, Clone)]
+pub struct CliOptions {
+ /// Number of concurrent workers
+ pub concurrency: usize,
+ /// Duration in seconds
+ pub duration: u64,
+ /// Rate limit (requests per second)
+ pub rate_limit: i64,
+ /// Total request limit
+ pub request_limit: i64,
+ /// Request timeout
+ pub timeout: u64,
+ /// Read timeout
+ pub read_timeout: u64,
+ /// Write timeout
+ pub write_timeout: u64,
+ /// Dial timeout
+ pub dial_timeout: u64,
+ /// Enable compression
+ pub compress: bool,
+ /// Total rounds
+ pub total_rounds: i64,
+ /// Debug mode
+ pub debug: bool,
+}
+
+impl Default for CliOptions {
+ fn default() -> Self {
+ Self {
+ concurrency: 1,
+ duration: 5,
+ rate_limit: -1,
+ request_limit: -1,
+ timeout: 0,
+ read_timeout: 0,
+ write_timeout: 0,
+ dial_timeout: 3,
+ compress: false,
+ total_rounds: -1,
+ debug: false,
+ }
+ }
+}
+
+/// Load generator
+pub struct LoadGenerator {
+ config: LoaderConfig,
+ options: CliOptions,
+ interrupted: Arc,
+ http_client: Arc,
+ provider: Arc,
+}
+
+impl LoadGenerator {
+ /// Create a new load generator
+ pub fn new(
+ config: LoaderConfig,
+ options: CliOptions,
+ interrupted: Arc,
+ ) -> Result {
+ // Create HTTP client
+ let http_config = HttpClientConfig {
+ timeout: options.timeout,
+ read_timeout: options.read_timeout,
+ write_timeout: options.write_timeout,
+ dial_timeout: options.dial_timeout,
+ compress: options.compress,
+ disable_header_normalizing: config.runner.disable_header_names_normalizing,
+ max_connections_per_host: options.concurrency,
+ ..Default::default()
+ };
+ let http_client = Arc::new(HttpClient::new(http_config)?);
+
+ // Create variable provider with environment variables
+ let env_vars: HashMap = std::env::vars().collect();
+ let provider = Arc::new(VariableProvider::new(&config.variables, env_vars));
+
+ Ok(Self {
+ config,
+ options,
+ interrupted,
+ http_client,
+ provider,
+ })
+ }
+
+ /// Run the load generator
+ pub async fn run(&mut self) -> Result {
+ // Override total_rounds from CLI if specified
+ let total_rounds = if self.options.total_rounds > 0 {
+ self.options.total_rounds
+ } else {
+ self.config.runner.total_rounds
+ };
+
+ // Warmup phase
+ if !self.config.runner.no_warm {
+ self.warmup().await?;
+ }
+
+ // Calculate request limit per worker
+ let request_limit = self.options.request_limit;
+ let concurrency = if request_limit > 0 && (request_limit as usize) < self.options.concurrency {
+ request_limit as usize
+ } else {
+ self.options.concurrency
+ };
+
+ let requests_per_worker = if request_limit > 0 {
+ (request_limit + concurrency as i64 - 1) / concurrency as i64
+ } else {
+ -1
+ };
+
+ // Create shared stats
+ let stats = Arc::new(LoadStats::new());
+
+ // Start wall clock
+ let wall_start = Instant::now();
+
+ // Spawn workers
+ let mut tasks = JoinSet::new();
+
+ for i in 0..concurrency {
+ let worker_request_limit = if requests_per_worker > 0 {
+ let remaining = request_limit - (i as i64 * requests_per_worker);
+ remaining.min(requests_per_worker)
+ } else {
+ -1
+ };
+
+ let worker_config = WorkerConfig {
+ duration: Duration::from_secs(self.options.duration),
+ request_limit: worker_request_limit,
+ rate_limit: self.options.rate_limit,
+ total_rounds,
+ compress: self.options.compress,
+ debug: self.options.debug,
+ };
+
+ let worker = Worker::new(
+ self.config.clone(),
+ worker_config,
+ Arc::clone(&self.http_client),
+ Arc::clone(&self.provider),
+ Arc::clone(&stats),
+ Arc::clone(&self.interrupted),
+ );
+
+ tasks.spawn(async move {
+ if let Err(e) = worker.run().await {
+ error!("Worker error: {}", e);
+ }
+ });
+ }
+
+ // Wait for all workers to complete
+ while let Some(result) = tasks.join_next().await {
+ if let Err(e) = result {
+ error!("Worker task panicked: {}", e);
+ }
+ }
+
+ let wall_time = wall_start.elapsed();
+
+ // Aggregate and print stats
+ let aggregated = AggregatedStats::from(stats.as_ref());
+
+ if aggregated.num_requests == 0 {
+ error!("Error: No statistics collected / no requests found");
+ } else {
+ aggregated.print(wall_time, &self.config.runner);
+ }
+
+ Ok(aggregated)
+ }
+
+ /// Run warmup phase
+ async fn warmup(&self) -> Result<()> {
+ info!("warmup started");
+
+ let global_ctx: HashMap = HashMap::new();
+
+ for item in &self.config.requests {
+ let _request = match &item.request {
+ Some(r) => r,
+ None => continue,
+ };
+
+ // Build request
+ let mut runtime_vars = global_ctx.clone();
+ let request_builder = RequestBuilder::new(
+ &self.provider,
+ &self.config.runner,
+ self.options.compress,
+ );
+
+ let req = match request_builder.build(item, &mut runtime_vars, self.http_client.client()) {
+ Ok(r) => r,
+ Err(e) => {
+ error!("Failed to build warmup request: {}", e);
+ continue;
+ }
+ };
+
+ info!("[{}] {}", req.method(), req.url());
+
+ // Execute request
+ let start = Instant::now();
+ let response = self.http_client.execute(req).await;
+ let duration = start.elapsed();
+
+ match response {
+ Ok(resp) => {
+ let status = resp.status().as_u16() as i32;
+ let body = resp.text().await.unwrap_or_default();
+
+ info!(
+ "status: {}, duration: {:?}, response: {}",
+ status,
+ duration,
+ if body.len() > 512 { &body[..512] } else { &body }
+ );
+
+ // Check if status indicates failure
+ let is_valid = self.config.runner.valid_status_codes_during_warmup.is_empty()
+ || self.config.runner.valid_status_codes_during_warmup.contains(&status);
+
+ if !is_valid && (status >= 400 || status == 0) {
+ warn!(
+ "Warmup request returned status {}, are you sure to continue?",
+ status
+ );
+ }
+ }
+ Err(e) => {
+ error!("Warmup request failed: {}", e);
+ warn!("Warmup request failed, are you sure to continue?");
+ }
+ }
+ }
+
+ info!("warmup finished");
+ Ok(())
+ }
+}
diff --git a/loadgen-rust/src/loader/mod.rs b/loadgen-rust/src/loader/mod.rs
new file mode 100644
index 0000000..e7f9f0e
--- /dev/null
+++ b/loadgen-rust/src/loader/mod.rs
@@ -0,0 +1,28 @@
+// Copyright (C) INFINI Labs & INFINI LIMITED.
+//
+// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0
+// and as commercial software.
+//
+// For commercial licensing, contact us at:
+// - Website: infinilabs.com
+// - Email: hello@infini.ltd
+//
+// Open Source licensed under AGPL V3:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Loader module for loadgen
+
+pub mod generator;
+pub mod stats;
+pub mod worker;
diff --git a/loadgen-rust/src/loader/stats.rs b/loadgen-rust/src/loader/stats.rs
new file mode 100644
index 0000000..a4296f0
--- /dev/null
+++ b/loadgen-rust/src/loader/stats.rs
@@ -0,0 +1,375 @@
+// Copyright (C) INFINI Labs & INFINI LIMITED.
+//
+// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0
+// and as commercial software.
+//
+// For commercial licensing, contact us at:
+// - Website: infinilabs.com
+// - Email: hello@infini.ltd
+//
+// Open Source licensed under AGPL V3:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Load statistics collection
+
+use std::collections::HashMap;
+use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
+use std::time::Duration;
+
+use bytesize::ByteSize;
+use hdrhistogram::Histogram;
+use parking_lot::Mutex;
+
+/// Statistics collected during load generation
+#[derive(Debug)]
+pub struct LoadStats {
+ /// Total request size in bytes
+ pub total_request_size: AtomicI64,
+ /// Total response size in bytes
+ pub total_response_size: AtomicI64,
+ /// Total duration of all requests
+ pub total_duration: AtomicU64,
+ /// Minimum request time
+ pub min_request_time: AtomicU64,
+ /// Maximum request time
+ pub max_request_time: AtomicU64,
+ /// Number of requests completed
+ pub num_requests: AtomicU64,
+ /// Number of errors
+ pub num_errors: AtomicU64,
+ /// Number of invalid assertions
+ pub num_assert_invalid: AtomicU64,
+ /// Number of skipped assertions
+ pub num_assert_skipped: AtomicU64,
+ /// Status code counts
+ pub status_codes: Mutex>,
+ /// Latency histogram
+ pub histogram: Mutex>,
+}
+
+impl Default for LoadStats {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl LoadStats {
+ /// Create new empty statistics
+ pub fn new() -> Self {
+ Self {
+ total_request_size: AtomicI64::new(0),
+ total_response_size: AtomicI64::new(0),
+ total_duration: AtomicU64::new(0),
+ min_request_time: AtomicU64::new(u64::MAX),
+ max_request_time: AtomicU64::new(0),
+ num_requests: AtomicU64::new(0),
+ num_errors: AtomicU64::new(0),
+ num_assert_invalid: AtomicU64::new(0),
+ num_assert_skipped: AtomicU64::new(0),
+ status_codes: Mutex::new(HashMap::new()),
+ histogram: Mutex::new(Histogram::new(3).unwrap()),
+ }
+ }
+
+ /// Record a request result
+ pub fn record(
+ &self,
+ duration: Duration,
+ request_size: i64,
+ response_size: i64,
+ status: i32,
+ is_error: bool,
+ ) {
+ let duration_us = duration.as_micros() as u64;
+
+ self.num_requests.fetch_add(1, Ordering::Relaxed);
+ self.total_duration.fetch_add(duration_us, Ordering::Relaxed);
+ self.total_request_size.fetch_add(request_size, Ordering::Relaxed);
+ self.total_response_size.fetch_add(response_size, Ordering::Relaxed);
+
+ // Update min/max atomically
+ let mut current_min = self.min_request_time.load(Ordering::Relaxed);
+ while duration_us < current_min {
+ match self.min_request_time.compare_exchange_weak(
+ current_min,
+ duration_us,
+ Ordering::SeqCst,
+ Ordering::Relaxed,
+ ) {
+ Ok(_) => break,
+ Err(x) => current_min = x,
+ }
+ }
+
+ let mut current_max = self.max_request_time.load(Ordering::Relaxed);
+ while duration_us > current_max {
+ match self.max_request_time.compare_exchange_weak(
+ current_max,
+ duration_us,
+ Ordering::SeqCst,
+ Ordering::Relaxed,
+ ) {
+ Ok(_) => break,
+ Err(x) => current_max = x,
+ }
+ }
+
+ // Update status codes
+ {
+ let mut codes = self.status_codes.lock();
+ *codes.entry(status).or_insert(0) += 1;
+ }
+
+ // Update histogram
+ {
+ let mut hist = self.histogram.lock();
+ let _ = hist.record(duration_us);
+ }
+
+ if is_error {
+ self.num_errors.fetch_add(1, Ordering::Relaxed);
+ }
+ }
+
+ /// Record an assertion failure
+ pub fn record_assert_invalid(&self) {
+ self.num_assert_invalid.fetch_add(1, Ordering::Relaxed);
+ }
+
+ /// Record a skipped assertion
+ pub fn record_assert_skipped(&self) {
+ self.num_assert_skipped.fetch_add(1, Ordering::Relaxed);
+ }
+
+ /// Merge another stats instance into this one
+ pub fn merge(&self, other: &LoadStats) {
+ self.total_request_size.fetch_add(
+ other.total_request_size.load(Ordering::Relaxed),
+ Ordering::Relaxed,
+ );
+ self.total_response_size.fetch_add(
+ other.total_response_size.load(Ordering::Relaxed),
+ Ordering::Relaxed,
+ );
+ self.total_duration.fetch_add(
+ other.total_duration.load(Ordering::Relaxed),
+ Ordering::Relaxed,
+ );
+ self.num_requests.fetch_add(
+ other.num_requests.load(Ordering::Relaxed),
+ Ordering::Relaxed,
+ );
+ self.num_errors.fetch_add(
+ other.num_errors.load(Ordering::Relaxed),
+ Ordering::Relaxed,
+ );
+ self.num_assert_invalid.fetch_add(
+ other.num_assert_invalid.load(Ordering::Relaxed),
+ Ordering::Relaxed,
+ );
+ self.num_assert_skipped.fetch_add(
+ other.num_assert_skipped.load(Ordering::Relaxed),
+ Ordering::Relaxed,
+ );
+
+ // Merge min
+ let other_min = other.min_request_time.load(Ordering::Relaxed);
+ let mut current_min = self.min_request_time.load(Ordering::Relaxed);
+ while other_min < current_min {
+ match self.min_request_time.compare_exchange_weak(
+ current_min,
+ other_min,
+ Ordering::SeqCst,
+ Ordering::Relaxed,
+ ) {
+ Ok(_) => break,
+ Err(x) => current_min = x,
+ }
+ }
+
+ // Merge max
+ let other_max = other.max_request_time.load(Ordering::Relaxed);
+ let mut current_max = self.max_request_time.load(Ordering::Relaxed);
+ while other_max > current_max {
+ match self.max_request_time.compare_exchange_weak(
+ current_max,
+ other_max,
+ Ordering::SeqCst,
+ Ordering::Relaxed,
+ ) {
+ Ok(_) => break,
+ Err(x) => current_max = x,
+ }
+ }
+
+ // Merge status codes
+ {
+ let other_codes = other.status_codes.lock();
+ let mut self_codes = self.status_codes.lock();
+ for (k, v) in other_codes.iter() {
+ *self_codes.entry(*k).or_insert(0) += v;
+ }
+ }
+
+ // Merge histogram
+ {
+ let other_hist = other.histogram.lock();
+ let mut self_hist = self.histogram.lock();
+ let _ = self_hist.add(&*other_hist);
+ }
+ }
+}
+
+/// Final aggregated statistics for display
+pub struct AggregatedStats {
+ pub num_requests: u64,
+ pub num_errors: u64,
+ pub num_assert_invalid: u64,
+ pub num_assert_skipped: u64,
+ pub total_request_size: i64,
+ pub total_response_size: i64,
+ pub min_request_time: Duration,
+ pub max_request_time: Duration,
+ pub avg_request_time: Duration,
+ pub total_duration: Duration,
+ pub status_codes: HashMap,
+ pub p50: Duration,
+ pub p75: Duration,
+ pub p95: Duration,
+ pub p99: Duration,
+ pub p999: Duration,
+}
+
+impl From<&LoadStats> for AggregatedStats {
+ fn from(stats: &LoadStats) -> Self {
+ let num_requests = stats.num_requests.load(Ordering::Relaxed);
+ let total_duration_us = stats.total_duration.load(Ordering::Relaxed);
+ let min_us = stats.min_request_time.load(Ordering::Relaxed);
+ let max_us = stats.max_request_time.load(Ordering::Relaxed);
+
+ let histogram = stats.histogram.lock();
+
+ AggregatedStats {
+ num_requests,
+ num_errors: stats.num_errors.load(Ordering::Relaxed),
+ num_assert_invalid: stats.num_assert_invalid.load(Ordering::Relaxed),
+ num_assert_skipped: stats.num_assert_skipped.load(Ordering::Relaxed),
+ total_request_size: stats.total_request_size.load(Ordering::Relaxed),
+ total_response_size: stats.total_response_size.load(Ordering::Relaxed),
+ min_request_time: Duration::from_micros(if min_us == u64::MAX { 0 } else { min_us }),
+ max_request_time: Duration::from_micros(max_us),
+ avg_request_time: if num_requests > 0 {
+ Duration::from_micros(total_duration_us / num_requests)
+ } else {
+ Duration::ZERO
+ },
+ total_duration: Duration::from_micros(total_duration_us),
+ status_codes: stats.status_codes.lock().clone(),
+ p50: Duration::from_micros(histogram.value_at_quantile(0.50)),
+ p75: Duration::from_micros(histogram.value_at_quantile(0.75)),
+ p95: Duration::from_micros(histogram.value_at_quantile(0.95)),
+ p99: Duration::from_micros(histogram.value_at_quantile(0.99)),
+ p999: Duration::from_micros(histogram.value_at_quantile(0.999)),
+ }
+ }
+}
+
+impl AggregatedStats {
+ /// Print statistics in the format matching the Go implementation
+ pub fn print(&self, wall_time: Duration, config: &crate::config::types::RunnerConfig) {
+ let req_rate = self.num_requests as f64 / wall_time.as_secs_f64();
+ let req_bytes_rate = self.total_request_size as f64 / wall_time.as_secs_f64();
+ let total_bytes_rate = (self.total_request_size + self.total_response_size) as f64
+ / wall_time.as_secs_f64();
+
+ // Summary line
+ if config.no_size_stats {
+ println!(
+ "\n{} requests finished in {:?}",
+ self.num_requests, wall_time
+ );
+ } else {
+ println!(
+ "\n{} requests finished in {:?}, {} sent, {} received",
+ self.num_requests,
+ wall_time,
+ ByteSize::b(self.total_request_size as u64),
+ ByteSize::b(self.total_response_size as u64)
+ );
+ }
+
+ // Client metrics
+ println!("\n[Loadgen Client Metrics]");
+ println!("Requests/sec:\t\t{:.2}", req_rate);
+
+ if !config.benchmark_only && !config.no_size_stats {
+ println!("Request Traffic/sec:\t{}/s", ByteSize::b(req_bytes_rate as u64));
+ println!("Total Transfer/sec:\t{}/s", ByteSize::b(total_bytes_rate as u64));
+ }
+
+ println!("Fastest Request:\t{:?}", self.min_request_time);
+ println!("Slowest Request:\t{:?}", self.max_request_time);
+
+ if config.assert_error {
+ println!("Number of Errors:\t{}", self.num_errors);
+ }
+
+ if config.assert_invalid {
+ println!("Assert Invalid:\t\t{}", self.num_assert_invalid);
+ println!("Assert Skipped:\t\t{}", self.num_assert_skipped);
+ }
+
+ // Status codes
+ let mut sorted_codes: Vec<_> = self.status_codes.iter().collect();
+ sorted_codes.sort_by_key(|(k, _)| *k);
+ for (code, count) in sorted_codes {
+ println!("Status {}:\t\t{}", code, count);
+ }
+
+ // Latency metrics
+ if !config.benchmark_only && !config.no_stats {
+ println!("\n[Latency Metrics]");
+ println!(
+ "{} samples of {} events",
+ self.num_requests, self.num_requests
+ );
+ println!("Avg:\t\t{:?}", self.avg_request_time);
+ println!("p50:\t\t{:?}", self.p50);
+ println!("p75:\t\t{:?}", self.p75);
+ println!("p95:\t\t{:?}", self.p95);
+ println!("p99:\t\t{:?}", self.p99);
+ println!("p999:\t\t{:?}", self.p999);
+ println!("Max:\t\t{:?}", self.max_request_time);
+ println!("Min:\t\t{:?}", self.min_request_time);
+ }
+
+ // Estimated server metrics
+ println!("\n[Estimated Server Metrics]");
+ let server_req_rate = if self.total_duration.as_secs_f64() > 0.0 {
+ self.num_requests as f64 / self.total_duration.as_secs_f64()
+ } else {
+ 0.0
+ };
+ println!("Requests/sec:\t\t{:.2}", server_req_rate);
+ println!("Avg Req Time:\t\t{:?}", self.avg_request_time);
+
+ if !config.benchmark_only && !config.no_size_stats {
+ let server_bytes_rate = (self.total_request_size + self.total_response_size) as f64
+ / self.total_duration.as_secs_f64();
+ println!("Transfer/sec:\t\t{}/s", ByteSize::b(server_bytes_rate as u64));
+ }
+
+ println!();
+ }
+}
diff --git a/loadgen-rust/src/loader/worker.rs b/loadgen-rust/src/loader/worker.rs
new file mode 100644
index 0000000..90afd26
--- /dev/null
+++ b/loadgen-rust/src/loader/worker.rs
@@ -0,0 +1,345 @@
+// Copyright (C) INFINI Labs & INFINI LIMITED.
+//
+// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0
+// and as commercial software.
+//
+// For commercial licensing, contact us at:
+// - Website: infinilabs.com
+// - Email: hello@infini.ltd
+//
+// Open Source licensed under AGPL V3:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Worker task for executing requests
+
+use std::collections::HashMap;
+use std::num::NonZeroU32;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use anyhow::Result;
+use governor::{Quota, RateLimiter};
+use tokio::time::sleep;
+use tracing::{debug, error, info, warn};
+
+use super::stats::LoadStats;
+use crate::assertion::conditions::AssertionChecker;
+use crate::config::types::{LoaderConfig, RequestItem};
+use crate::http::client::HttpClient;
+use crate::http::request::RequestBuilder;
+use crate::variable::provider::VariableProvider;
+
+/// Worker configuration
+pub struct WorkerConfig {
+ /// Maximum duration for the worker to run
+ pub duration: Duration,
+ /// Maximum number of requests to send (-1 = unlimited)
+ pub request_limit: i64,
+ /// Rate limit (requests per second, -1 = unlimited)
+ pub rate_limit: i64,
+ /// Total number of rounds to run (-1 = unlimited)
+ pub total_rounds: i64,
+ /// Enable compression
+ pub compress: bool,
+ /// Debug mode
+ pub debug: bool,
+}
+
+/// Worker for executing HTTP requests
+pub struct Worker {
+ config: LoaderConfig,
+ worker_config: WorkerConfig,
+ http_client: Arc,
+ provider: Arc,
+ stats: Arc,
+ interrupted: Arc,
+ rate_limiter: Option>>,
+}
+
+impl Worker {
+ /// Create a new worker
+ pub fn new(
+ config: LoaderConfig,
+ worker_config: WorkerConfig,
+ http_client: Arc,
+ provider: Arc,
+ stats: Arc,
+ interrupted: Arc,
+ ) -> Self {
+ // Create rate limiter if needed
+ let rate_limiter = if worker_config.rate_limit > 0 {
+ let rate = NonZeroU32::new(worker_config.rate_limit as u32)
+ .unwrap_or(NonZeroU32::new(1).unwrap());
+ let quota = Quota::per_second(rate);
+ Some(Arc::new(RateLimiter::direct(quota)))
+ } else {
+ None
+ };
+
+ Self {
+ config,
+ worker_config,
+ http_client,
+ provider,
+ stats,
+ interrupted,
+ rate_limiter,
+ }
+ }
+
+ /// Run the worker
+ pub async fn run(&self) -> Result<()> {
+ let start = Instant::now();
+ let mut total_requests = 0i64;
+ let mut total_rounds = 0i64;
+ let mut global_ctx: HashMap = HashMap::new();
+
+ loop {
+ // Check if interrupted
+ if self.interrupted.load(Ordering::Relaxed) {
+ debug!("Worker interrupted");
+ break;
+ }
+
+ // Check duration limit
+ if start.elapsed() >= self.worker_config.duration {
+ debug!("Duration limit reached");
+ break;
+ }
+
+ // Check rounds limit
+ if self.worker_config.total_rounds > 0 && total_rounds >= self.worker_config.total_rounds {
+ debug!("Rounds limit reached");
+ break;
+ }
+
+ total_rounds += 1;
+
+ // Execute all requests in the config
+ for item in &self.config.requests {
+ // Check request limit
+ if self.worker_config.request_limit > 0
+ && total_requests >= self.worker_config.request_limit
+ {
+ debug!("Request limit reached");
+ return Ok(());
+ }
+
+ // Rate limiting
+ if let Some(ref limiter) = self.rate_limiter {
+ while limiter.check().is_err() {
+ if self.interrupted.load(Ordering::Relaxed) {
+ return Ok(());
+ }
+ sleep(Duration::from_millis(10)).await;
+ }
+ }
+
+ // Execute request
+ let continue_next = self.execute_request(item, &mut global_ctx).await?;
+ total_requests += 1;
+
+ if !continue_next {
+ break;
+ }
+ }
+ }
+
+ debug!("Worker completed: {} requests in {:?}", total_requests, start.elapsed());
+ Ok(())
+ }
+
+ /// Execute a single request
+ async fn execute_request(
+ &self,
+ item: &RequestItem,
+ global_ctx: &mut HashMap,
+ ) -> Result {
+ let request = match &item.request {
+ Some(r) => r,
+ None => return Ok(true),
+ };
+
+ let repeat_times = if request.execute_repeat_times > 0 {
+ request.execute_repeat_times as usize
+ } else {
+ 1
+ };
+
+ for _ in 0..repeat_times {
+ let mut runtime_vars = global_ctx.clone();
+
+ // Build request
+ let request_builder = RequestBuilder::new(
+ &self.provider,
+ &self.config.runner,
+ self.worker_config.compress,
+ );
+
+ let req = match request_builder.build(item, &mut runtime_vars, self.http_client.client()) {
+ Ok(r) => r,
+ Err(e) => {
+ error!("Failed to build request: {}", e);
+ self.stats.num_errors.fetch_add(1, Ordering::Relaxed);
+ continue;
+ }
+ };
+
+ // Get request size
+ let request_size = req.body().map(|b| b.as_bytes().map(|b| b.len()).unwrap_or(0)).unwrap_or(0) as i64;
+
+ // Log request if configured
+ if self.config.runner.log_requests {
+ info!(
+ "[{}] {}, {:?}",
+ req.method(),
+ req.url(),
+ req.headers()
+ );
+ }
+
+ // Execute request
+ let start = Instant::now();
+ let response = self.http_client.execute(req).await;
+ let duration = start.elapsed();
+
+ match response {
+ Ok(resp) => {
+ let status = resp.status().as_u16() as i32;
+ let response_size = resp.content_length().unwrap_or(0) as i64;
+
+ // Log response if configured
+ if self.config.runner.log_requests
+ || self.config.runner.log_status_codes.contains(&status)
+ {
+ info!("status: {}, duration: {:?}", status, duration);
+ }
+
+ // Check for error status
+ let is_error = status >= 400 || status == 0;
+ self.stats.record(duration, request_size, response_size, status, is_error);
+
+ // Handle assertions
+ if let Some(ref assert_config) = item.assert {
+ // Get response body for assertions
+ let body = resp.text().await.unwrap_or_default();
+
+ // Build context for assertions
+ let ctx = build_assert_context(status, &body, duration);
+
+ // Check assertions
+ let checker = AssertionChecker::new(&ctx);
+ if !checker.check(assert_config) {
+ self.stats.record_assert_invalid();
+
+ if !self.config.runner.continue_on_assert_invalid {
+ warn!(
+ "{} {}, assertion failed, skipping subsequent requests",
+ request.method, request.url
+ );
+ return Ok(false);
+ }
+ }
+ }
+
+ // Handle register
+ for register_map in &item.register {
+ for (dest, src) in register_map {
+ if let Some(value) = get_ctx_value(&build_assert_context(status, "", duration), src) {
+ global_ctx.insert(dest.clone(), value);
+ }
+ }
+ }
+ }
+ Err(e) => {
+ error!("Request failed: {}", e);
+ self.stats.record(duration, request_size, 0, 0, true);
+ self.stats.record_assert_invalid();
+ }
+ }
+
+ // Handle sleep
+ if let Some(ref sleep_action) = item.sleep {
+ if sleep_action.sleep_in_milli_seconds > 0 {
+ sleep(Duration::from_millis(sleep_action.sleep_in_milli_seconds as u64)).await;
+ }
+ }
+ }
+
+ Ok(true)
+ }
+}
+
+/// Build assertion context from response
+fn build_assert_context(status: i32, body: &str, duration: Duration) -> HashMap {
+ let mut ctx = HashMap::new();
+
+ // Response context
+ ctx.insert("_ctx.response.status".to_string(), serde_json::json!(status));
+ ctx.insert("_ctx.response.body".to_string(), serde_json::json!(body));
+ ctx.insert("_ctx.response.body_length".to_string(), serde_json::json!(body.len()));
+ ctx.insert("_ctx.elapsed".to_string(), serde_json::json!(duration.as_millis() as i64));
+
+ // Try to parse body as JSON
+ if let Ok(json) = serde_json::from_str::(body) {
+ ctx.insert("_ctx.response.body_json".to_string(), json);
+ }
+
+ ctx
+}
+
+/// Get a value from the context by path
+fn get_ctx_value(ctx: &HashMap, path: &str) -> Option {
+ // Direct lookup first
+ if let Some(value) = ctx.get(path) {
+ return Some(value.to_string());
+ }
+
+ // Try JSON path lookup for body_json
+ if path.starts_with("_ctx.response.body_json.") {
+ if let Some(body_json) = ctx.get("_ctx.response.body_json") {
+ let json_path = path.strip_prefix("_ctx.response.body_json.")?;
+ return get_json_value(body_json, json_path);
+ }
+ }
+
+ None
+}
+
+/// Get a value from JSON by path (e.g., "foo.bar.baz")
+fn get_json_value(json: &serde_json::Value, path: &str) -> Option {
+ let parts: Vec<&str> = path.split('.').collect();
+ let mut current = json;
+
+ for part in parts {
+ match current {
+ serde_json::Value::Object(map) => {
+ current = map.get(part)?;
+ }
+ serde_json::Value::Array(arr) => {
+ let idx: usize = part.parse().ok()?;
+ current = arr.get(idx)?;
+ }
+ _ => return None,
+ }
+ }
+
+ match current {
+ serde_json::Value::String(s) => Some(s.clone()),
+ serde_json::Value::Number(n) => Some(n.to_string()),
+ serde_json::Value::Bool(b) => Some(b.to_string()),
+ _ => Some(current.to_string()),
+ }
+}
diff --git a/loadgen-rust/src/main.rs b/loadgen-rust/src/main.rs
new file mode 100644
index 0000000..370e9c8
--- /dev/null
+++ b/loadgen-rust/src/main.rs
@@ -0,0 +1,281 @@
+// Copyright (C) INFINI Labs & INFINI LIMITED.
+//
+// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0
+// and as commercial software.
+//
+// For commercial licensing, contact us at:
+// - Website: infinilabs.com
+// - Email: hello@infini.ltd
+//
+// Open Source licensed under AGPL V3:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! INFINI Loadgen - Main entry point
+//!
+//! A high-performance HTTP load generator and testing suite.
+
+use anyhow::Result;
+use clap::Parser;
+use std::path::{Path, PathBuf};
+use std::process::ExitCode;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+use tracing::{error, info};
+
+use loadgen::config::yaml::load_config;
+use loadgen::loader::generator::LoadGenerator;
+use loadgen::runner::test::TestRunner;
+
+/// CLI argument parser
+#[derive(Parser, Debug)]
+#[command(
+ name = "loadgen",
+ about = "A high-performance HTTP load generator and testing suite",
+ version,
+ author = "INFINI Labs ",
+ before_help = r#"
+ __ ___ _ ___ ___ __ __
+ / / /___\/_\ / \/ _ \ /__\/\ \ \
+ / / // ///_\\ / /\ / /_\//_\ / \/ /
+/ /__/ \_// _ \/ /_// /_\\//__/ /\ /
+\____|___/\_/ \_/___,'\____/\__/\_\ \/
+
+HOME: https://github.com/infinilabs/loadgen/
+"#
+)]
+struct Cli {
+ /// Number of concurrent threads to use
+ #[arg(short = 'c', long = "concurrency", default_value = "1")]
+ concurrency: usize,
+
+ /// Duration of the test in seconds
+ #[arg(short = 'd', long = "duration", default_value = "5")]
+ duration: u64,
+
+ /// Maximum requests per second (fixed QPS), -1 for unlimited
+ #[arg(short = 'r', long = "rate", default_value = "-1")]
+ rate_limit: i64,
+
+ /// Total number of requests to send, -1 for unlimited
+ #[arg(short = 'l', long = "limit", default_value = "-1")]
+ request_limit: i64,
+
+ /// Request timeout in seconds, 0 for no timeout
+ #[arg(long = "timeout", default_value = "0")]
+ timeout: u64,
+
+ /// Connection read timeout in seconds, 0 inherits from timeout
+ #[arg(long = "read-timeout", default_value = "0")]
+ read_timeout: u64,
+
+ /// Connection write timeout in seconds, 0 inherits from timeout
+ #[arg(long = "write-timeout", default_value = "0")]
+ write_timeout: u64,
+
+ /// Connection dial timeout in seconds
+ #[arg(long = "dial-timeout", default_value = "3")]
+ dial_timeout: u64,
+
+ /// Enable gzip compression for requests
+ #[arg(long = "compress", default_value = "false")]
+ compress: bool,
+
+ /// Enable mixed requests from YAML/DSL
+ #[arg(long = "mixed", default_value = "false")]
+ mixed: bool,
+
+ /// Number of rounds for each request configuration, -1 for unlimited
+ #[arg(long = "total-rounds", default_value = "-1")]
+ total_rounds: i64,
+
+ /// Path to a DSL-based request file to execute
+ #[arg(long = "run")]
+ dsl_file: Option,
+
+ /// Path to YAML config file
+ #[arg(short = 'C', long = "config", default_value = "loadgen.yml")]
+ config: PathBuf,
+
+ /// Log level (trace, debug, info, warn, error)
+ #[arg(long = "log", default_value = "info")]
+ log_level: String,
+
+ /// Run in debug mode
+ #[arg(long = "debug", default_value = "false")]
+ debug: bool,
+}
+
+fn print_header() {
+ println!(
+ r#"
+ __ ___ _ ___ ___ __ __
+ / / /___\/_\ / \/ _ \ /__\/\ \ \
+ / / // ///_\\ / /\ / /_\//_\ / \/ /
+/ /__/ \_// _ \/ /_// /_\\//__/ /\ /
+\____|___/\_/ \_/___,'\____/\__/\_\ \/
+
+HOME: https://github.com/infinilabs/loadgen/
+"#
+ );
+}
+
+fn setup_logging(log_level: &str, debug: bool) {
+ let level = if debug {
+ "debug"
+ } else {
+ log_level
+ };
+
+ let filter = tracing_subscriber::EnvFilter::try_from_default_env()
+ .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(level));
+
+ tracing_subscriber::fmt()
+ .with_env_filter(filter)
+ .with_target(false)
+ .with_thread_ids(false)
+ .init();
+}
+
+#[tokio::main]
+async fn main() -> ExitCode {
+ let cli = Cli::parse();
+
+ print_header();
+ setup_logging(&cli.log_level, cli.debug);
+
+ // Setup Ctrl+C handler
+ let interrupted = Arc::new(AtomicBool::new(false));
+ let interrupted_clone = Arc::clone(&interrupted);
+
+ ctrlc::set_handler(move || {
+ info!("Received interrupt signal, shutting down...");
+ interrupted_clone.store(true, Ordering::SeqCst);
+ })
+ .expect("Failed to set Ctrl+C handler");
+
+ // Load configuration
+ let config = match load_config(&cli.config).await {
+ Ok(config) => config,
+ Err(e) => {
+ error!("Failed to load config: {}", e);
+ return ExitCode::from(1);
+ }
+ };
+
+ // Create CLI options
+ let cli_options = loadgen::loader::generator::CliOptions {
+ concurrency: cli.concurrency,
+ duration: cli.duration,
+ rate_limit: cli.rate_limit,
+ request_limit: cli.request_limit,
+ timeout: cli.timeout,
+ read_timeout: cli.read_timeout,
+ write_timeout: cli.write_timeout,
+ dial_timeout: cli.dial_timeout,
+ compress: cli.compress,
+ total_rounds: cli.total_rounds,
+ debug: cli.debug,
+ };
+
+ let mut exit_code = 0;
+
+ // Run DSL file if specified
+ if let Some(dsl_file) = &cli.dsl_file {
+ info!("Running DSL based requests from {:?}", dsl_file);
+ match run_dsl_file(&config, dsl_file, &cli_options, Arc::clone(&interrupted)).await {
+ Ok(status) => {
+ if status != 0 {
+ exit_code = status;
+ }
+ }
+ Err(e) => {
+ error!("Failed to run DSL file: {}", e);
+ return ExitCode::from(1);
+ }
+ }
+ if !cli.mixed {
+ return ExitCode::from(exit_code as u8);
+ }
+ }
+
+ // Run YAML-based requests
+ if !config.requests.is_empty() {
+ info!("Running YAML based requests");
+ match run_loader(&config, &cli_options, Arc::clone(&interrupted)).await {
+ Ok(status) => {
+ if status != 0 {
+ exit_code = status;
+ }
+ }
+ Err(e) => {
+ error!("Failed to run loader: {}", e);
+ return ExitCode::from(2);
+ }
+ }
+ if !cli.mixed {
+ return ExitCode::from(exit_code as u8);
+ }
+ }
+
+ // Run test suite if configured
+ if !config.tests.is_empty() {
+ info!("Running test suite");
+ let runner = TestRunner::new(&config);
+ if !runner.run().await {
+ exit_code = 1;
+ }
+ }
+
+ ExitCode::from(exit_code as u8)
+}
+
+async fn run_dsl_file(
+ config: &loadgen::AppConfig,
+ path: &Path,
+ cli_options: &loadgen::loader::generator::CliOptions,
+ interrupted: Arc,
+) -> Result {
+ use loadgen::config::dsl::parse_dsl_file;
+
+ let loader_config = parse_dsl_file(path, config).await?;
+ run_loader_config(&loader_config, cli_options, interrupted).await
+}
+
+async fn run_loader(
+ config: &loadgen::AppConfig,
+ cli_options: &loadgen::loader::generator::CliOptions,
+ interrupted: Arc,
+) -> Result {
+ let loader_config = config.to_loader_config();
+ run_loader_config(&loader_config, cli_options, interrupted).await
+}
+
+async fn run_loader_config(
+ config: &loadgen::LoaderConfig,
+ cli_options: &loadgen::loader::generator::CliOptions,
+ interrupted: Arc,
+) -> Result {
+ let mut generator = LoadGenerator::new(config.clone(), cli_options.clone(), interrupted)?;
+ let stats = generator.run().await?;
+
+ // Check assertions
+ if config.runner.assert_invalid && stats.num_assert_invalid > 0 {
+ return Ok(1);
+ }
+ if config.runner.assert_error && stats.num_errors > 0 {
+ return Ok(2);
+ }
+
+ Ok(0)
+}
diff --git a/loadgen-rust/src/runner/mod.rs b/loadgen-rust/src/runner/mod.rs
new file mode 100644
index 0000000..2d57c7d
--- /dev/null
+++ b/loadgen-rust/src/runner/mod.rs
@@ -0,0 +1,26 @@
+// Copyright (C) INFINI Labs & INFINI LIMITED.
+//
+// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0
+// and as commercial software.
+//
+// For commercial licensing, contact us at:
+// - Website: infinilabs.com
+// - Email: hello@infini.ltd
+//
+// Open Source licensed under AGPL V3:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Runner module for loadgen
+
+pub mod test;
diff --git a/loadgen-rust/src/runner/test.rs b/loadgen-rust/src/runner/test.rs
new file mode 100644
index 0000000..ffe4715
--- /dev/null
+++ b/loadgen-rust/src/runner/test.rs
@@ -0,0 +1,174 @@
+// Copyright (C) INFINI Labs & INFINI LIMITED.
+//
+// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0
+// and as commercial software.
+//
+// For commercial licensing, contact us at:
+// - Website: infinilabs.com
+// - Email: hello@infini.ltd
+//
+// Open Source licensed under AGPL V3:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Test runner for executing test suites
+
+use std::path::Path;
+use std::sync::atomic::AtomicBool;
+use std::sync::Arc;
+use std::time::Instant;
+
+use chrono::Local;
+use tracing::{debug, error, info};
+
+use crate::config::dsl::parse_dsl_file;
+use crate::config::types::AppConfig;
+use crate::loader::generator::{CliOptions, LoadGenerator};
+
+/// Test result
+#[derive(Debug)]
+pub struct TestResult {
+ pub failed: bool,
+ pub duration_ms: i64,
+ pub error: Option,
+}
+
+/// Test message for reporting
+#[derive(Debug)]
+pub struct TestMsg {
+ pub path: String,
+ pub status: String, // ABORTED/FAILED/SUCCESS
+ pub duration_ms: i64,
+}
+
+/// Test runner for executing test suites
+pub struct TestRunner<'a> {
+ config: &'a AppConfig,
+}
+
+impl<'a> TestRunner<'a> {
+ /// Create a new test runner
+ pub fn new(config: &'a AppConfig) -> Self {
+ Self { config }
+ }
+
+ /// Run all tests
+ pub async fn run(&self) -> bool {
+ let mut results: Vec = Vec::new();
+
+ for test in &self.config.tests {
+ // Wait between tests
+ tokio::time::sleep(std::time::Duration::from_secs(1)).await;
+
+ let result = self.run_test(test).await;
+ let msg = TestMsg {
+ path: test.path.clone(),
+ status: match &result {
+ Ok(r) if r.failed => "FAILED".to_string(),
+ Ok(_) => "SUCCESS".to_string(),
+ Err(_) => "ABORTED".to_string(),
+ },
+ duration_ms: result.as_ref().map(|r| r.duration_ms).unwrap_or(0),
+ };
+ results.push(msg);
+ }
+
+ // Print summary
+ let mut all_ok = true;
+ for msg in &results {
+ let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S");
+ info!(
+ "[{}][TEST][{}] [{}] duration: {}ms",
+ timestamp, msg.status, msg.path, msg.duration_ms
+ );
+ if msg.status != "SUCCESS" {
+ all_ok = false;
+ }
+ }
+
+ all_ok
+ }
+
+ /// Run a single test
+ async fn run_test(&self, test: &crate::config::types::Test) -> anyhow::Result {
+ let start = Instant::now();
+ let mut result = TestResult {
+ failed: false,
+ duration_ms: 0,
+ error: None,
+ };
+
+ // Find DSL file
+ let dsl_path = Path::new(&test.path).join("loadgen.dsl");
+ if !dsl_path.exists() {
+ debug!("DSL file not found: {:?}", dsl_path);
+ result.error = Some(format!("DSL file not found: {:?}", dsl_path));
+ result.failed = true;
+ result.duration_ms = start.elapsed().as_millis() as i64;
+ return Ok(result);
+ }
+
+ // Parse DSL
+ let loader_config = match parse_dsl_file(&dsl_path, self.config).await {
+ Ok(c) => c,
+ Err(e) => {
+ error!("Failed to parse DSL: {}", e);
+ result.error = Some(format!("Failed to parse DSL: {}", e));
+ result.failed = true;
+ result.duration_ms = start.elapsed().as_millis() as i64;
+ return Ok(result);
+ }
+ };
+
+ // Create generator
+ let cli_options = CliOptions {
+ compress: test.compress,
+ ..Default::default()
+ };
+
+ let interrupted = Arc::new(AtomicBool::new(false));
+ let mut generator = match LoadGenerator::new(loader_config.clone(), cli_options, interrupted) {
+ Ok(g) => g,
+ Err(e) => {
+ error!("Failed to create generator: {}", e);
+ result.error = Some(format!("Failed to create generator: {}", e));
+ result.failed = true;
+ result.duration_ms = start.elapsed().as_millis() as i64;
+ return Ok(result);
+ }
+ };
+
+ // Run generator
+ let stats = match generator.run().await {
+ Ok(s) => s,
+ Err(e) => {
+ error!("Generator failed: {}", e);
+ result.error = Some(format!("Generator failed: {}", e));
+ result.failed = true;
+ result.duration_ms = start.elapsed().as_millis() as i64;
+ return Ok(result);
+ }
+ };
+
+ // Check results
+ if loader_config.runner.assert_invalid && stats.num_assert_invalid > 0 {
+ result.failed = true;
+ }
+ if loader_config.runner.assert_error && stats.num_errors > 0 {
+ result.failed = true;
+ }
+
+ result.duration_ms = start.elapsed().as_millis() as i64;
+ Ok(result)
+ }
+}
diff --git a/loadgen-rust/src/template/engine.rs b/loadgen-rust/src/template/engine.rs
new file mode 100644
index 0000000..7cd2ba0
--- /dev/null
+++ b/loadgen-rust/src/template/engine.rs
@@ -0,0 +1,206 @@
+// Copyright (C) INFINI Labs & INFINI LIMITED.
+//
+// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0
+// and as commercial software.
+//
+// For commercial licensing, contact us at:
+// - Website: infinilabs.com
+// - Email: hello@infini.ltd
+//
+// Open Source licensed under AGPL V3:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Template engine for variable substitution
+//!
+//! Supports the $[[variable_name]] syntax used by loadgen
+
+use std::collections::HashMap;
+
+/// Template engine for processing variable substitutions
+pub struct TemplateEngine;
+
+/// Start delimiter for variables
+const START_DELIM: &str = "$[[";
+/// End delimiter for variables
+const END_DELIM: &str = "]]";
+
+impl TemplateEngine {
+ /// Check if a string contains template variables
+ pub fn has_template(input: &str) -> bool {
+ input.contains(START_DELIM)
+ }
+
+ /// Execute template substitution with a callback function for variable resolution
+ pub fn execute(input: &str, resolver: F) -> String
+ where
+ F: Fn(&str) -> String,
+ {
+ if !Self::has_template(input) {
+ return input.to_string();
+ }
+
+ let mut result = String::with_capacity(input.len());
+ let mut remaining = input;
+
+ while let Some(start_idx) = remaining.find(START_DELIM) {
+ // Append text before the variable
+ result.push_str(&remaining[..start_idx]);
+
+ // Find the end of the variable
+ let after_start = &remaining[start_idx + START_DELIM.len()..];
+ if let Some(end_idx) = after_start.find(END_DELIM) {
+ let var_name = &after_start[..end_idx];
+ let value = resolver(var_name);
+ result.push_str(&value);
+ remaining = &after_start[end_idx + END_DELIM.len()..];
+ } else {
+ // No closing delimiter found, append the rest and break
+ result.push_str(&remaining[start_idx..]);
+ remaining = "";
+ break;
+ }
+ }
+
+ // Append remaining text
+ result.push_str(remaining);
+ result
+ }
+
+ /// Execute template substitution with a HashMap of variables
+ pub fn execute_with_map(input: &str, variables: &HashMap) -> String {
+ Self::execute(input, |name| {
+ variables
+ .get(name)
+ .cloned()
+ .unwrap_or_else(|| format!("$[[{}]]", name))
+ })
+ }
+
+ /// Extract all variable names from a template string
+ pub fn extract_variables(input: &str) -> Vec {
+ let mut variables = Vec::new();
+ let mut remaining = input;
+
+ while let Some(start_idx) = remaining.find(START_DELIM) {
+ let after_start = &remaining[start_idx + START_DELIM.len()..];
+ if let Some(end_idx) = after_start.find(END_DELIM) {
+ let var_name = &after_start[..end_idx];
+ variables.push(var_name.to_string());
+ remaining = &after_start[end_idx + END_DELIM.len()..];
+ } else {
+ break;
+ }
+ }
+
+ variables
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_has_template() {
+ assert!(TemplateEngine::has_template("Hello $[[name]]!"));
+ assert!(TemplateEngine::has_template("$[[a]] $[[b]]"));
+ assert!(!TemplateEngine::has_template("Hello world!"));
+ assert!(!TemplateEngine::has_template("$[name]"));
+ }
+
+ #[test]
+ fn test_execute_simple() {
+ let result = TemplateEngine::execute("Hello $[[name]]!", |var| {
+ if var == "name" {
+ "World".to_string()
+ } else {
+ format!("unknown:{}", var)
+ }
+ });
+ assert_eq!(result, "Hello World!");
+ }
+
+ #[test]
+ fn test_execute_multiple() {
+ let result = TemplateEngine::execute("$[[a]] and $[[b]]", |var| {
+ match var {
+ "a" => "first".to_string(),
+ "b" => "second".to_string(),
+ _ => "unknown".to_string(),
+ }
+ });
+ assert_eq!(result, "first and second");
+ }
+
+ #[test]
+ fn test_execute_no_template() {
+ let result = TemplateEngine::execute("No template here", |_| "unused".to_string());
+ assert_eq!(result, "No template here");
+ }
+
+ #[test]
+ fn test_execute_with_map() {
+ let mut vars = HashMap::new();
+ vars.insert("name".to_string(), "Alice".to_string());
+ vars.insert("greeting".to_string(), "Hello".to_string());
+
+ let result = TemplateEngine::execute_with_map("$[[greeting]] $[[name]]!", &vars);
+ assert_eq!(result, "Hello Alice!");
+ }
+
+ #[test]
+ fn test_execute_missing_variable() {
+ let vars = HashMap::new();
+ let result = TemplateEngine::execute_with_map("Hello $[[name]]!", &vars);
+ assert_eq!(result, "Hello $[[name]]!");
+ }
+
+ #[test]
+ fn test_extract_variables() {
+ let vars = TemplateEngine::extract_variables("$[[a]] and $[[b.c]] = $[[d]]");
+ assert_eq!(vars, vec!["a", "b.c", "d"]);
+ }
+
+ #[test]
+ fn test_execute_nested_json() {
+ let result = TemplateEngine::execute(
+ r#"{"id": "$[[id]]", "name": "$[[name]]"}"#,
+ |var| match var {
+ "id" => "123".to_string(),
+ "name" => "test".to_string(),
+ _ => "".to_string(),
+ },
+ );
+ assert_eq!(result, r#"{"id": "123", "name": "test"}"#);
+ }
+
+ #[test]
+ fn test_execute_env_variable() {
+ let result = TemplateEngine::execute(
+ "http://$[[env.ES_HOST]]:$[[env.ES_PORT]]/index",
+ |var| match var {
+ "env.ES_HOST" => "localhost".to_string(),
+ "env.ES_PORT" => "9200".to_string(),
+ _ => "".to_string(),
+ },
+ );
+ assert_eq!(result, "http://localhost:9200/index");
+ }
+
+ #[test]
+ fn test_execute_unclosed_delimiter() {
+ let result = TemplateEngine::execute("Hello $[[name", |_| "World".to_string());
+ assert_eq!(result, "Hello $[[name");
+ }
+}
diff --git a/loadgen-rust/src/template/mod.rs b/loadgen-rust/src/template/mod.rs
new file mode 100644
index 0000000..1780b55
--- /dev/null
+++ b/loadgen-rust/src/template/mod.rs
@@ -0,0 +1,28 @@
+// Copyright (C) INFINI Labs & INFINI LIMITED.
+//
+// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0
+// and as commercial software.
+//
+// For commercial licensing, contact us at:
+// - Website: infinilabs.com
+// - Email: hello@infini.ltd
+//
+// Open Source licensed under AGPL V3:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Template module for loadgen
+
+pub mod engine;
+
+pub use engine::TemplateEngine;
diff --git a/loadgen-rust/src/variable/mod.rs b/loadgen-rust/src/variable/mod.rs
new file mode 100644
index 0000000..406fd80
--- /dev/null
+++ b/loadgen-rust/src/variable/mod.rs
@@ -0,0 +1,27 @@
+// Copyright (C) INFINI Labs & INFINI LIMITED.
+//
+// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0
+// and as commercial software.
+//
+// For commercial licensing, contact us at:
+// - Website: infinilabs.com
+// - Email: hello@infini.ltd
+//
+// Open Source licensed under AGPL V3:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Variable module for loadgen
+
+pub mod provider;
+pub mod types;
diff --git a/loadgen-rust/src/variable/provider.rs b/loadgen-rust/src/variable/provider.rs
new file mode 100644
index 0000000..62b4c1b
--- /dev/null
+++ b/loadgen-rust/src/variable/provider.rs
@@ -0,0 +1,420 @@
+// Copyright (C) INFINI Labs & INFINI LIMITED.
+//
+// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0
+// and as commercial software.
+//
+// For commercial licensing, contact us at:
+// - Website: infinilabs.com
+// - Email: hello@infini.ltd
+//
+// Open Source licensed under AGPL V3:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Variable provider - generates values for variables
+
+use std::collections::HashMap;
+use std::fs::File;
+use std::io::{BufRead, BufReader};
+use std::sync::Arc;
+
+use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
+use chrono::{Local, Utc};
+use rand::Rng;
+use roaring::RoaringBitmap;
+use uuid::Uuid;
+
+use super::types::{SequenceCounter32, SequenceCounter64, VariableType};
+use crate::config::types::Variable;
+
+/// Timestamp format for now_utc_lite
+const TS_LAYOUT: &str = "%Y-%m-%dT%H:%M:%S%.3f";
+
+/// Variable provider for generating variable values
+pub struct VariableProvider {
+ /// Variable definitions
+ variables: HashMap,
+ /// Data loaded from files or inline definitions
+ dict: HashMap>,
+ /// 32-bit sequence counters
+ seq32: HashMap>,
+ /// 64-bit sequence counters
+ seq64: HashMap>,
+ /// Character replacers
+ replacers: HashMap>,
+ /// Environment variables
+ env_vars: HashMap,
+}
+
+impl VariableProvider {
+ /// Create a new variable provider
+ pub fn new(variables: &[Variable], env_vars: HashMap) -> Self {
+ let mut provider = VariableProvider {
+ variables: HashMap::new(),
+ dict: HashMap::new(),
+ seq32: HashMap::new(),
+ seq64: HashMap::new(),
+ replacers: HashMap::new(),
+ env_vars,
+ };
+
+ for var in variables {
+ provider.register_variable(var.clone());
+ }
+
+ provider
+ }
+
+ /// Register a variable
+ pub fn register_variable(&mut self, var: Variable) {
+ let name = var.name.trim().to_string();
+
+ // Load data from file if specified
+ let mut lines: Vec = Vec::new();
+
+ if !var.path.is_empty() {
+ if let Ok(file) = File::open(&var.path) {
+ let reader = BufReader::new(file);
+ for line in reader.lines().map_while(Result::ok) {
+ let trimmed = line.trim().to_string();
+ if !trimmed.is_empty() {
+ lines.push(trimmed);
+ }
+ }
+ }
+ }
+
+ // Add inline data
+ for item in &var.data {
+ let trimmed = item.trim().to_string();
+ if !trimmed.is_empty() {
+ lines.push(trimmed);
+ }
+ }
+
+ // Store replacer if specified
+ if !var.replace.is_empty() {
+ let replacements: Vec<(String, String)> = var
+ .replace
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect();
+ self.replacers.insert(name.clone(), replacements);
+ }
+
+ // Create sequence counters
+ let var_type = VariableType::from(var.var_type.as_str());
+ match var_type {
+ VariableType::Sequence => {
+ let counter = Arc::new(SequenceCounter32::new(var.from as u32, var.to as u32));
+ self.seq32.insert(name.clone(), counter);
+ }
+ VariableType::Sequence64 => {
+ let counter = Arc::new(SequenceCounter64::new(var.from, var.to));
+ self.seq64.insert(name.clone(), counter);
+ }
+ _ => {}
+ }
+
+ self.dict.insert(name.clone(), lines);
+ self.variables.insert(name, var);
+ }
+
+ /// Get a variable value
+ pub fn get_value(&self, key: &str, runtime_vars: Option<&HashMap>) -> String {
+ // Check runtime variables first
+ if let Some(vars) = runtime_vars {
+ if let Some(value) = vars.get(key) {
+ return value.clone();
+ }
+ }
+
+ // Check for environment variable reference
+ if let Some(env_key) = key.strip_prefix("env.") {
+ return self.env_vars.get(env_key).cloned().unwrap_or_default();
+ }
+
+ // Get variable definition
+ let var = match self.variables.get(key) {
+ Some(v) => v,
+ None => return "not_found".to_string(),
+ };
+
+ let raw_value = self.build_variable_value(var);
+
+ // Apply replacer if exists
+ if let Some(replacements) = self.replacers.get(key) {
+ let mut result = raw_value;
+ for (from, to) in replacements {
+ result = result.replace(from, to);
+ }
+ return result;
+ }
+
+ raw_value
+ }
+
+ /// Build a variable value based on its type
+ fn build_variable_value(&self, var: &Variable) -> String {
+ let var_type = VariableType::from(var.var_type.as_str());
+
+ match var_type {
+ VariableType::Sequence => {
+ if let Some(counter) = self.seq32.get(&var.name) {
+ return counter.next().to_string();
+ }
+ "0".to_string()
+ }
+ VariableType::Sequence64 => {
+ if let Some(counter) = self.seq64.get(&var.name) {
+ return counter.next().to_string();
+ }
+ "0".to_string()
+ }
+ VariableType::Uuid => Uuid::new_v4().to_string(),
+ VariableType::NowLocal => Local::now().to_string(),
+ VariableType::NowUtc => Utc::now().to_string(),
+ VariableType::NowUtcLite => Utc::now().format(TS_LAYOUT).to_string(),
+ VariableType::NowUnix => Local::now().timestamp().to_string(),
+ VariableType::NowUnixInMs => Local::now().timestamp_millis().to_string(),
+ VariableType::NowUnixInMicro => Local::now().timestamp_micros().to_string(),
+ VariableType::NowUnixInNano => {
+ Local::now().timestamp_nanos_opt().unwrap_or(0).to_string()
+ }
+ VariableType::NowWithFormat => {
+ if var.format.is_empty() {
+ panic!("Date format is not set for variable: {}", var.name);
+ }
+ // Convert Go date format to chrono format
+ let format = convert_go_time_format(&var.format);
+ Local::now().format(&format).to_string()
+ }
+ VariableType::Range => {
+ let mut rng = rand::thread_rng();
+ let range = var.to - var.from + 1;
+ let value = rng.gen_range(0..range) + var.from;
+ value.to_string()
+ }
+ VariableType::RandomArray => self.build_random_array(var),
+ VariableType::IntArrayBitmap => self.build_int_array_bitmap(var),
+ VariableType::File | VariableType::List => {
+ if let Some(data) = self.dict.get(&var.name) {
+ if data.is_empty() {
+ return String::new();
+ }
+ if data.len() == 1 {
+ return data[0].clone();
+ }
+ let mut rng = rand::thread_rng();
+ let idx = rng.gen_range(0..data.len());
+ return data[idx].clone();
+ }
+ String::new()
+ }
+ VariableType::Env => {
+ self.env_vars.get(&var.name).cloned().unwrap_or_default()
+ }
+ }
+ }
+
+ /// Build a random array value
+ fn build_random_array(&self, var: &Variable) -> String {
+ let mut result = String::new();
+
+ if var.square_bracket {
+ result.push('[');
+ }
+
+ if !var.variable_key.is_empty() && var.size > 0 {
+ for i in 0..var.size {
+ if i > 0 {
+ result.push(',');
+ }
+
+ // Get value from referenced variable
+ let value = self.get_value(&var.variable_key, None);
+
+ // Add string brackets if needed
+ if var.variable_type == "string" {
+ let bracket = if var.string_bracket.is_empty() {
+ "\""
+ } else {
+ &var.string_bracket
+ };
+ result.push_str(bracket);
+ result.push_str(&value);
+ result.push_str(bracket);
+ } else {
+ result.push_str(&value);
+ }
+ }
+ }
+
+ if var.square_bracket {
+ result.push(']');
+ }
+
+ result
+ }
+
+ /// Build a roaring bitmap encoded integer array
+ fn build_int_array_bitmap(&self, var: &Variable) -> String {
+ let mut bitmap = RoaringBitmap::new();
+ let mut rng = rand::thread_rng();
+
+ if var.size > 0 {
+ for _ in 0..var.size {
+ let range = var.to - var.from + 1;
+ let value = rng.gen_range(0..range) + var.from;
+ bitmap.insert(value as u32);
+ }
+ }
+
+ let mut bytes = Vec::new();
+ bitmap.serialize_into(&mut bytes).unwrap_or_default();
+ BASE64.encode(&bytes)
+ }
+
+ /// Clear all data (for reset_context)
+ pub fn clear(&mut self) {
+ self.dict.clear();
+ self.seq32.clear();
+ self.seq64.clear();
+ self.variables.clear();
+ self.replacers.clear();
+ }
+}
+
+/// Convert Go time format to chrono strftime format
+fn convert_go_time_format(go_format: &str) -> String {
+ // Go uses a reference time: Mon Jan 2 15:04:05 MST 2006
+ // This maps Go format specifiers to chrono format specifiers
+ // Order matters - replace longer patterns first to avoid partial matches
+ go_format
+ // Year - must be first to avoid issues with other numbers
+ .replace("2006", "%Y")
+ .replace("06", "%y")
+ // Month patterns - order by length (longer first)
+ .replace("January", "%B")
+ .replace("Jan", "%b")
+ .replace("01", "%m")
+ // Day patterns
+ .replace("Monday", "%A")
+ .replace("Mon", "%a")
+ .replace("02", "%d")
+ // Hour patterns - must be before minute patterns
+ .replace("15", "%H") // 24-hour
+ .replace("03", "%I") // 12-hour with leading zero
+ // Minute patterns - must be after hour patterns
+ .replace("04", "%M")
+ // Second patterns
+ .replace("05", "%S")
+ // Timezone patterns
+ .replace("-0700", "%z")
+ .replace("-07:00", "%:z")
+ .replace("-07", "%z")
+ .replace("MST", "%Z")
+ // AM/PM
+ .replace("PM", "%p")
+ .replace("pm", "%P")
+ // Subseconds
+ .replace(".000000000", "%.9f")
+ .replace(".000000", "%.6f")
+ .replace(".000", "%.3f")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_sequence_variable() {
+ let vars = vec![Variable {
+ name: "id".to_string(),
+ var_type: "sequence".to_string(),
+ from: 1,
+ to: 100,
+ ..Default::default()
+ }];
+
+ let provider = VariableProvider::new(&vars, HashMap::new());
+
+ assert_eq!(provider.get_value("id", None), "1");
+ assert_eq!(provider.get_value("id", None), "2");
+ assert_eq!(provider.get_value("id", None), "3");
+ }
+
+ #[test]
+ fn test_uuid_variable() {
+ let vars = vec![Variable {
+ name: "uuid".to_string(),
+ var_type: "uuid".to_string(),
+ ..Default::default()
+ }];
+
+ let provider = VariableProvider::new(&vars, HashMap::new());
+
+ let value = provider.get_value("uuid", None);
+ assert!(Uuid::parse_str(&value).is_ok());
+ }
+
+ #[test]
+ fn test_range_variable() {
+ let vars = vec![Variable {
+ name: "num".to_string(),
+ var_type: "range".to_string(),
+ from: 10,
+ to: 20,
+ ..Default::default()
+ }];
+
+ let provider = VariableProvider::new(&vars, HashMap::new());
+
+ for _ in 0..100 {
+ let value: u64 = provider.get_value("num", None).parse().unwrap();
+ assert!(value >= 10 && value <= 20);
+ }
+ }
+
+ #[test]
+ fn test_list_variable() {
+ let vars = vec![Variable {
+ name: "user".to_string(),
+ var_type: "list".to_string(),
+ data: vec!["alice".to_string(), "bob".to_string(), "charlie".to_string()],
+ ..Default::default()
+ }];
+
+ let provider = VariableProvider::new(&vars, HashMap::new());
+
+ let value = provider.get_value("user", None);
+ assert!(["alice", "bob", "charlie"].contains(&value.as_str()));
+ }
+
+ #[test]
+ fn test_env_variable() {
+ let mut env_vars = HashMap::new();
+ env_vars.insert("TEST_VAR".to_string(), "test_value".to_string());
+
+ let provider = VariableProvider::new(&[], env_vars);
+
+ assert_eq!(provider.get_value("env.TEST_VAR", None), "test_value");
+ }
+
+ #[test]
+ fn test_convert_go_time_format() {
+ assert_eq!(convert_go_time_format("2006-01-02"), "%Y-%m-%d");
+ assert_eq!(convert_go_time_format("15:04:05"), "%H:%M:%S");
+ assert_eq!(convert_go_time_format("2006-01-02T15:04:05-0700"), "%Y-%m-%dT%H:%M:%S%z");
+ }
+}
diff --git a/loadgen-rust/src/variable/types.rs b/loadgen-rust/src/variable/types.rs
new file mode 100644
index 0000000..a784d05
--- /dev/null
+++ b/loadgen-rust/src/variable/types.rs
@@ -0,0 +1,144 @@
+// Copyright (C) INFINI Labs & INFINI LIMITED.
+//
+// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0
+// and as commercial software.
+//
+// For commercial licensing, contact us at:
+// - Website: infinilabs.com
+// - Email: hello@infini.ltd
+//
+// Open Source licensed under AGPL V3:
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+//! Variable type definitions
+
+use std::sync::atomic::{AtomicU32, AtomicU64};
+
+/// Variable types supported by loadgen
+#[derive(Debug, Clone, PartialEq)]
+pub enum VariableType {
+ /// Load values from a file
+ File,
+ /// Inline list of values
+ List,
+ /// 32-bit auto-incrementing sequence
+ Sequence,
+ /// 64-bit auto-incrementing sequence
+ Sequence64,
+ /// Random number in range
+ Range,
+ /// UUID v4
+ Uuid,
+ /// Current local time
+ NowLocal,
+ /// Current UTC time
+ NowUtc,
+ /// Current UTC time in compact format
+ NowUtcLite,
+ /// Unix timestamp in seconds
+ NowUnix,
+ /// Unix timestamp in milliseconds
+ NowUnixInMs,
+ /// Unix timestamp in microseconds
+ NowUnixInMicro,
+ /// Unix timestamp in nanoseconds
+ NowUnixInNano,
+ /// Custom formatted time
+ NowWithFormat,
+ /// Random array from another variable
+ RandomArray,
+ /// Roaring bitmap encoded integer array
+ IntArrayBitmap,
+ /// Environment variable
+ Env,
+}
+
+impl From<&str> for VariableType {
+ fn from(s: &str) -> Self {
+ match s.to_lowercase().as_str() {
+ "file" => VariableType::File,
+ "list" => VariableType::List,
+ "sequence" => VariableType::Sequence,
+ "sequence64" => VariableType::Sequence64,
+ "range" => VariableType::Range,
+ "uuid" => VariableType::Uuid,
+ "now_local" => VariableType::NowLocal,
+ "now_utc" => VariableType::NowUtc,
+ "now_utc_lite" => VariableType::NowUtcLite,
+ "now_unix" => VariableType::NowUnix,
+ "now_unix_in_ms" => VariableType::NowUnixInMs,
+ "now_unix_in_micro" => VariableType::NowUnixInMicro,
+ "now_unix_in_nano" => VariableType::NowUnixInNano,
+ "now_with_format" => VariableType::NowWithFormat,
+ "random_array" => VariableType::RandomArray,
+ "int_array_bitmap" => VariableType::IntArrayBitmap,
+ "env" => VariableType::Env,
+ _ => VariableType::List, // Default to list
+ }
+ }
+}
+
+/// A sequence counter for auto-incrementing variables
+#[derive(Debug)]
+pub struct SequenceCounter32 {
+ current: AtomicU32,
+ from: u32,
+ to: u32,
+}
+
+impl SequenceCounter32 {
+ pub fn new(from: u32, to: u32) -> Self {
+ Self {
+ current: AtomicU32::new(from),
+ from,
+ to,
+ }
+ }
+
+ pub fn next(&self) -> u32 {
+ let current = self.current.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
+ if self.to > 0 && current >= self.to {
+ self.current.store(self.from, std::sync::atomic::Ordering::SeqCst);
+ return self.from;
+ }
+ current
+ }
+}
+
+/// A sequence counter for 64-bit auto-incrementing variables
+#[derive(Debug)]
+pub struct SequenceCounter64 {
+ current: AtomicU64,
+ from: u64,
+ to: u64,
+}
+
+impl SequenceCounter64 {
+ pub fn new(from: u64, to: u64) -> Self {
+ Self {
+ current: AtomicU64::new(from),
+ from,
+ to,
+ }
+ }
+
+ pub fn next(&self) -> u64 {
+ let current = self.current.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
+ if self.to > 0 && current >= self.to {
+ self.current.store(self.from, std::sync::atomic::Ordering::SeqCst);
+ return self.from;
+ }
+ current
+ }
+}