diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index 8af59dd..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[env] -RUST_TEST_THREADS = "1" diff --git a/Cargo.lock b/Cargo.lock index 1b734da..f6d2107 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,7 +54,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom 0.3.3", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -121,6 +121,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ar_archive_writer" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c269894b6fe5e9d7ada0cf69b5bf847ff35bc25fc271f08e1d080fce80339a" +dependencies = [ + "object 0.32.2", +] + [[package]] name = "argon2" version = "0.5.3" @@ -197,7 +206,7 @@ dependencies = [ "futures-timer", "futures-util", "http", - "indexmap 2.11.4", + "indexmap 2.12.0", "mime", "multer", "num-traits", @@ -246,7 +255,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ecdaff7c9cffa3614a9f9999bf9ee4c3078fe3ce4d6a6e161736b56febf2de" dependencies = [ "bytes", - "indexmap 2.11.4", + "indexmap 2.12.0", "serde", "serde_json", ] @@ -373,9 +382,14 @@ dependencies = [ "axum", "chrono", "dotenvy", + "env", + "err", "futures", + "hlt", + "log 0.1.0", "serde", "serde_json", + "serial_test", "ssh2", "surrealdb", "tokio", @@ -394,7 +408,7 @@ dependencies = [ "cfg-if", "libc", "miniz_oxide", - "object", + "object 0.37.3", "rustc-demangle", "windows-link", ] @@ -413,9 +427,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.6.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" [[package]] name = "bcrypt" @@ -566,9 +580,9 @@ dependencies = [ [[package]] name = "bytemuck" -version = "1.23.2" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" [[package]] name = "byteorder" @@ -596,9 +610,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.39" +version = "1.2.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1354349954c6fc9cb0deab020f27f783cf0b604e8bb754dc4658ecf0d29c35f" +checksum = "739eb0f94557554b3ca9a86d2d37bebd49c5e6d0c1d2bda35ba5bdac830befc2" dependencies = [ "find-msvc-tools", "shlex", @@ -909,9 +923,9 @@ checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" [[package]] name = "deranged" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", "serde_core", @@ -1025,7 +1039,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d248bdd43ce613d87415282f69b9bb99d947d290b10962dd6c56233312c2ad5" dependencies = [ - "log", + "log 0.4.28", ] [[package]] @@ -1043,12 +1057,34 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" +[[package]] +name = "env" +version = "0.1.0" +dependencies = [ + "err", + "serial_test", + "tokio", + "tracing", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "err" +version = "0.1.0" +dependencies = [ + "axum", + "serde", + "serde_json", + "surrealdb", + "thiserror 2.0.17", + "tracing", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -1078,9 +1114,9 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "find-msvc-tools" -version = "0.1.2" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" [[package]] name = "fixedbitset" @@ -1256,9 +1292,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", @@ -1274,7 +1310,7 @@ dependencies = [ "float_next_after", "geo-types", "geographiclib-rs", - "log", + "log 0.4.28", "num-traits", "robust", "rstar", @@ -1312,21 +1348,21 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi", - "wasi 0.14.7+wasi-0.2.4", + "wasip2", "wasm-bindgen", ] @@ -1338,12 +1374,13 @@ checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "half" -version = "2.6.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "zerocopy", ] [[package]] @@ -1415,6 +1452,20 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hlt" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "chrono", + "futures", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1430,7 +1481,7 @@ version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" dependencies = [ - "log", + "log 0.4.28", "markup5ever", "match_token", ] @@ -1523,7 +1574,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.2", + "webpki-roots 1.0.3", ] [[package]] @@ -1560,7 +1611,7 @@ dependencies = [ "core-foundation-sys", "iana-time-zone-haiku", "js-sys", - "log", + "log 0.4.28", "wasm-bindgen", "windows-core 0.62.1", ] @@ -1700,9 +1751,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.11.4" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" dependencies = [ "equivalent", "hashbrown 0.16.0", @@ -1781,9 +1832,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" -version = "0.3.81" +version = "0.3.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" dependencies = [ "once_cell", "wasm-bindgen", @@ -1918,14 +1969,23 @@ checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" [[package]] name = "lock_api" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] +[[package]] +name = "log" +version = "0.1.0" +dependencies = [ + "env", + "serial_test", + "tracing", + "tracing-subscriber", +] + [[package]] name = "log" version = "0.4.28" @@ -1956,7 +2016,7 @@ version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" dependencies = [ - "log", + "log 0.4.28", "tendril", "web_atoms", ] @@ -2068,7 +2128,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.59.0", ] @@ -2162,11 +2222,11 @@ dependencies = [ [[package]] name = "nu-ansi-term" -version = "0.50.1" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2223,6 +2283,15 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + [[package]] name = "object" version = "0.37.3" @@ -2282,9 +2351,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -2292,15 +2361,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -2340,12 +2409,12 @@ dependencies = [ [[package]] name = "pem" -version = "3.0.5" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ "base64 0.22.1", - "serde", + "serde_core", ] [[package]] @@ -2356,12 +2425,11 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e0a3a33733faeaf8651dfee72dd0f388f0c8e5ad496a3478fa5a922f49cfa8" +checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4" dependencies = [ "memchr", - "thiserror 2.0.17", "ucd-trie", ] @@ -2372,7 +2440,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap 2.11.4", + "indexmap 2.12.0", ] [[package]] @@ -2519,10 +2587,11 @@ checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" [[package]] name = "psm" -version = "0.1.26" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e944464ec8536cd1beb0bbfd96987eb5e3b72f2ecdafdc5c769a37f1fa2ae1f" +checksum = "d11f2fedc3b7dafdc2851bc52f277377c5473d378859be234bc7ebb593144d01" dependencies = [ + "ar_archive_writer", "cc", ] @@ -2585,7 +2654,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ "bytes", - "getrandom 0.3.3", + "getrandom 0.3.4", "lru-slab", "rand 0.9.2", "ring", @@ -2610,7 +2679,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -2701,7 +2770,7 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", ] [[package]] @@ -2738,9 +2807,9 @@ checksum = "bbc4a4ea2a66a41a1152c4b3d86e8954dc087bdf33af35446e6e176db4e73c8c" [[package]] name = "redox_syscall" -version = "0.5.17" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ "bitflags", ] @@ -2778,9 +2847,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.3" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", @@ -2790,9 +2859,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.11" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", @@ -2801,9 +2870,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "rend" @@ -2816,9 +2885,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.23" +version = "0.12.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ "base64 0.22.1", "bytes", @@ -2831,7 +2900,7 @@ dependencies = [ "hyper-rustls", "hyper-util", "js-sys", - "log", + "log 0.4.28", "mime_guess", "percent-encoding", "pin-project-lite", @@ -2853,7 +2922,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.2", + "webpki-roots 1.0.3", ] [[package]] @@ -2986,9 +3055,9 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.38.0" +version = "1.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8975fc98059f365204d635119cf9c5a60ae67b841ed49b5422a9a7e56cdfac0" +checksum = "35affe401787a9bd846712274d97654355d21b2a2c092a3139aabe31e9022282" dependencies = [ "arrayvec", "borsh", @@ -3032,11 +3101,11 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.32" +version = "0.23.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" +checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" dependencies = [ - "log", + "log 0.4.28", "once_cell", "ring", "rustls-pki-types", @@ -3047,9 +3116,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.12.0" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" dependencies = [ "web-time", "zeroize", @@ -3096,6 +3165,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "schemars" version = "0.9.0" @@ -3138,6 +3216,12 @@ dependencies = [ "sha2", ] +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "seahash" version = "4.1.0" @@ -3205,7 +3289,7 @@ version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.12.0", "itoa", "memchr", "ryu", @@ -3238,19 +3322,18 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.14.1" +version = "3.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c522100790450cf78eeac1507263d0a350d4d5b30df0c8e1fe051a10c22b376e" +checksum = "aa66c845eee442168b2c8134fec70ac50dc20e760769c8ba0ad1319ca1959b04" dependencies = [ "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.11.4", + "indexmap 2.12.0", "schemars 0.9.0", "schemars 1.0.4", - "serde", - "serde_derive", + "serde_core", "serde_json", "serde_with_macros", "time", @@ -3258,9 +3341,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.14.1" +version = "3.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "327ada00f7d64abaac1e55a6911e90cf665aa051b9a561c7006c157f4633135e" +checksum = "b91a903660542fced4e99881aa481bdbaec1634568ee02e0b8bd57c64cb38955" dependencies = [ "darling 0.21.3", "proc-macro2", @@ -3268,6 +3351,31 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "serial_test" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" +dependencies = [ + "futures", + "log 0.4.28", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3398,15 +3506,15 @@ dependencies = [ [[package]] name = "stable_deref_trait" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stacker" -version = "0.1.21" +version = "0.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cddb07e32ddb770749da91081d8d0ac3a16f1a569a18b20348cd371f5dead06b" +checksum = "e1f8b29fb42aafcea4edeeb6b2f2d7ecd0d969c48b4cf0d2e64aafc471dd6e59" dependencies = [ "cc", "cfg-if", @@ -3505,8 +3613,8 @@ dependencies = [ "dmp", "futures", "geo", - "getrandom 0.3.3", - "indexmap 2.11.4", + "getrandom 0.3.4", + "indexmap 2.12.0", "path-clean", "pharos", "reblessive", @@ -3565,7 +3673,7 @@ dependencies = [ "fuzzy-matcher", "geo", "geo-types", - "getrandom 0.3.3", + "getrandom 0.3.4", "hex", "http", "ipnet", @@ -3866,7 +3974,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd" dependencies = [ "futures-util", - "log", + "log 0.4.28", "rustls", "rustls-pki-types", "tokio", @@ -3891,20 +3999,20 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.6" +version = "0.23.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3effe7c0e86fdff4f69cdd2ccc1b96f933e24811c5441d44904e8683e27184b" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" dependencies = [ - "indexmap 2.11.4", + "indexmap 2.12.0", "toml_datetime", "toml_parser", "winnow", @@ -3912,9 +4020,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cf893c33be71572e0e9aa6dd15e6677937abd686b066eac3f8cd3531688a627" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" dependencies = [ "winnow", ] @@ -3973,7 +4081,7 @@ version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ - "log", + "log 0.4.28", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4006,7 +4114,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" dependencies = [ - "log", + "log 0.4.28", "once_cell", "tracing-core", ] @@ -4070,7 +4178,7 @@ dependencies = [ "data-encoding", "http", "httparse", - "log", + "log 0.4.28", "rand 0.8.5", "rustls", "rustls-pki-types", @@ -4194,7 +4302,7 @@ version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", "js-sys", "serde", "wasm-bindgen", @@ -4249,15 +4357,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" version = "1.0.1+wasi-0.2.4" @@ -4269,9 +4368,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" dependencies = [ "cfg-if", "once_cell", @@ -4280,25 +4379,11 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.106", - "wasm-bindgen-shared", -] - [[package]] name = "wasm-bindgen-futures" -version = "0.4.54" +version = "0.4.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" dependencies = [ "cfg-if", "js-sys", @@ -4309,9 +4394,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4319,22 +4404,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn 2.0.106", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" dependencies = [ "unicode-ident", ] @@ -4367,9 +4452,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.81" +version = "0.3.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" dependencies = [ "js-sys", "wasm-bindgen", @@ -4403,14 +4488,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.2", + "webpki-roots 1.0.3", ] [[package]] name = "webpki-roots" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +checksum = "32b130c0d2d49f8b6889abc456e795e82525204f27c42cf767cf0d7734e089b8" dependencies = [ "rustls-pki-types", ] @@ -4437,7 +4522,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.1", + "windows-sys 0.59.0", ] [[package]] @@ -4453,7 +4538,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" dependencies = [ "windows-core 0.57.0", - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4465,7 +4550,7 @@ dependencies = [ "windows-implement 0.57.0", "windows-interface 0.57.0", "windows-result 0.1.2", - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4474,8 +4559,8 @@ version = "0.62.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9" dependencies = [ - "windows-implement 0.60.1", - "windows-interface 0.59.2", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link", "windows-result 0.4.0", "windows-strings", @@ -4494,9 +4579,9 @@ dependencies = [ [[package]] name = "windows-implement" -version = "0.60.1" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edb307e42a74fb6de9bf3a02d9712678b22399c87e6fa869d6dfcd8c1b7754e0" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", @@ -4516,9 +4601,9 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.59.2" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0abd1ddbc6964ac14db11c7213d6532ef34bd9aa042c2e5935f59d7908b46a5" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", @@ -4537,7 +4622,7 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4564,7 +4649,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -4573,25 +4658,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 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.4", -] - -[[package]] -name = "windows-sys" -version = "0.61.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" -dependencies = [ - "windows-link", + "windows-targets", ] [[package]] @@ -4600,31 +4667,14 @@ 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.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -4633,96 +4683,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" - [[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.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" - [[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.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" - [[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.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" - [[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.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" - [[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.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" - [[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.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" - [[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.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" - [[package]] name = "winnow" version = "0.7.13" @@ -4753,7 +4755,7 @@ dependencies = [ "async_io_stream", "futures", "js-sys", - "log", + "log 0.4.28", "pharos", "rustc_version", "send_wrapper", diff --git a/Cargo.toml b/Cargo.toml index f01d392..65b2468 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,24 +4,61 @@ version = "0.1.0" edition = "2024" [dependencies] +# Workspace dependencies +axum.workspace = true +serde.workspace = true +serde_json.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +tokio.workspace = true +chrono.workspace = true + +# Our crates +err.workspace = true +env.workspace = true +hlt.workspace = true +log.workspace = true +# Other dependencies async-trait = "0.1.89" -axum = "0.8.6" -chrono = "0.4.42" + dotenvy = "0.15.7" futures = "0.3.31" -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.145" ssh2 = "0.9.5" surrealdb = "2.3.10" -tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } tower-http = { version = "0.6.6", features = ["trace", "request-id"] } + +[dev-dependencies] +serial_test = "3.2.0" + + + + + + + +[workspace] +resolver = "3" +members = ["crates/env", "crates/err", "crates/hlt", "crates/log"] + + +# Shared dependencies across all crates +[workspace.dependencies] +axum = "0.8.6" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" +chrono = "0.4.42" tracing = "0.1.41" tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] } +tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } +err = { path = "crates/err" } +env = { path = "crates/env" } +hlt = { path = "crates/hlt" } +log = { path = "crates/log" } - +# Profile settings apply to entire workspace [profile.release] -opt-level = "z" # Optimize for size -lto = true # Link-Time Optimization -codegen-units = 1 # Better optimization at cost of compile time -strip = true # Strip symbols automatically -panic = "abort" # Smaller panic handler +opt-level = "z" +lto = true +codegen-units = 1 +strip = true +panic = "abort" diff --git a/Cargo.toml.backup b/Cargo.toml.backup new file mode 100644 index 0000000..f01d392 --- /dev/null +++ b/Cargo.toml.backup @@ -0,0 +1,27 @@ +[package] +name = "axum_backend" +version = "0.1.0" +edition = "2024" + +[dependencies] +async-trait = "0.1.89" +axum = "0.8.6" +chrono = "0.4.42" +dotenvy = "0.15.7" +futures = "0.3.31" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" +ssh2 = "0.9.5" +surrealdb = "2.3.10" +tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } +tower-http = { version = "0.6.6", features = ["trace", "request-id"] } +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] } + + +[profile.release] +opt-level = "z" # Optimize for size +lto = true # Link-Time Optimization +codegen-units = 1 # Better optimization at cost of compile time +strip = true # Strip symbols automatically +panic = "abort" # Smaller panic handler diff --git a/crates/env/Cargo.toml b/crates/env/Cargo.toml new file mode 100644 index 0000000..2b01431 --- /dev/null +++ b/crates/env/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "env" +version = "0.1.0" +edition = "2024" + +[dependencies] +err.workspace = true +tracing.workspace = true + + + +[dev-dependencies] +serial_test = "3.2.0" +# For testing +tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/env/README.md b/crates/env/README.md new file mode 100644 index 0000000..482d7ef --- /dev/null +++ b/crates/env/README.md @@ -0,0 +1,11 @@ +# `env` Crate + +Utilities for reading and parsing environment variables. + +## Functions + +- `get_required`: Get a variable or return an error. +- `get_or_default`: Get a variable or return a default value. +- `get_parsed`: Get and parse a variable or return an error. +- `get_parsed_or_default`: Get and parse a variable or return a default value. +- `get_bool`: Get a boolean variable with flexible parsing. diff --git a/crates/env/src/lib.rs b/crates/env/src/lib.rs new file mode 100644 index 0000000..b4b12d2 --- /dev/null +++ b/crates/env/src/lib.rs @@ -0,0 +1,11 @@ +//! # Environment Variable Utilities +//! +//! This crate provides convenient functions for accessing and parsing +//! environment variables. + +mod loader; +pub use err::EnvironmentError; +pub use loader::{get_bool, get_or_default, get_parsed, get_parsed_or_default, get_required}; + +/// A result type for environment variable operations. +pub type EnvResult = std::result::Result; diff --git a/crates/env/src/loader.rs b/crates/env/src/loader.rs new file mode 100644 index 0000000..fb3972e --- /dev/null +++ b/crates/env/src/loader.rs @@ -0,0 +1,317 @@ +//! Loads and parses environment variables. + +use super::EnvResult; +use err::EnvironmentError; +use std::{env, str::FromStr}; +use tracing::debug; + +/// Retrieves a required environment variable as a String. +/// +/// # Errors +/// +/// Returns `EnvironmentError::NotFoundError` if the variable is not set. +pub fn get_required(key: &str) -> EnvResult { + env::var(key).map_err(|_| { + debug!(key = %key, "Environment variable not found"); + EnvironmentError::NotFoundError(key.to_string()) + }) +} + +/// Retrieves an optional environment variable with a default value. +#[must_use] +pub fn get_or_default(key: &str, default: &str) -> String { + env::var(key).unwrap_or_else(|_| { + debug!( + key = %key, + default = %default, + "Using default value for environment variable" + ); + default.to_string() + }) +} + +/// Retrieves and parses an environment variable. +/// +/// # Type Parameters +/// +/// * `T` - Must implement `FromStr` +/// +/// # Errors +/// +/// - `EnvironmentError::NotFoundError` if the variable is not set +/// - `EnvironmentError::Parse` if the variable cannot be parsed +pub fn get_parsed(key: &str) -> EnvResult +where + T: FromStr, +{ + let value = get_required(key)?; + value.parse::().map_err(|_| { + debug!( + key=%key, + // value = %value, + type_name=std::any::type_name::(), + "Failed to parse environment variable" + ); + EnvironmentError::Parse { + key: key.to_string(), + value: value.clone(), + type_name: std::any::type_name::(), + } + }) +} + +/// Retrieves and parses an environment variable with a default value. +/// +/// # Type Parameters +/// +/// * `T` - Must implement `FromStr` and `Debug` +pub fn get_parsed_or_default(key: &str, default: T) -> T +where + T: FromStr + std::fmt::Debug, +{ + env::var(key) + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or_else(|| { + debug!(key=%key, + default = ?default, + "Using default parsed value for environment variable"); + default + }) +} + +/// Retrieves a boolean environment variable. +/// +/// Accepts multiple formats (case-insensitive): +/// - `true`, `1`, `yes`, `on` → `true` +/// - `false`, `0`, `no`, `off` → `false` +#[must_use] +pub fn get_bool(key: &str, default: bool) -> bool { + env::var(key) + .ok() + .and_then(|v| match v.to_lowercase().as_str() { + "true" | "1" | "yes" | "on" => Some(true), + "false" | "0" | "no" | "off" => Some(false), + _ => { + debug!( + key = %key, + // value = %v, + "Invalid boolean value, using default" + ); + None + } + }) + .unwrap_or_else(|| { + debug!( + key = %key, + default = %default, + "Boolean environment variable not found, using default" + ); + default + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + #[test] + #[serial] + fn test_get_required_success() { + unsafe { + std::env::set_var("TEST_VAR", "test_value"); + } + + let result = get_required("TEST_VAR"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "test_value"); + + unsafe { + std::env::remove_var("TEST_VAR"); + } + } + + #[test] + #[serial] + fn test_get_required_missing() { + unsafe { + std::env::remove_var("NONEXISTENT_VAR"); + } + + let result = get_required("NONEXISTENT_VAR"); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + EnvironmentError::NotFoundError(_) + )); + } + + #[test] + #[serial] + fn test_get_or_default_existing() { + unsafe { + std::env::set_var("TEST_DEFAULT", "actual_value"); + } + + let result = get_or_default("TEST_DEFAULT", "default_value"); + assert_eq!(result, "actual_value"); + + unsafe { + std::env::remove_var("TEST_DEFAULT"); + } + } + + #[test] + #[serial] + fn test_get_or_default_missing() { + unsafe { + std::env::remove_var("MISSING_VAR"); + } + + let result = get_or_default("MISSING_VAR", "default_value"); + assert_eq!(result, "default_value"); + } + + #[test] + #[serial] + fn test_get_parsed_success() { + unsafe { + std::env::set_var("TEST_PORT", "8080"); + } + + let result: Result = get_parsed("TEST_PORT"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 8080); + + unsafe { + std::env::remove_var("TEST_PORT"); + } + } + + #[test] + #[serial] + fn test_get_parsed_invalid_type() { + unsafe { + std::env::set_var("TEST_PORT", "invalid"); + } + + let result: Result = get_parsed("TEST_PORT"); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + EnvironmentError::Parse { .. } + )); + + unsafe { + std::env::remove_var("TEST_PORT"); + } + } + + #[test] + #[serial] + fn test_get_parsed_or_default() { + unsafe { + std::env::set_var("TEST_NUM", "42"); + } + let result: i32 = get_parsed_or_default("TEST_NUM", 100); + assert_eq!(result, 42); + + unsafe { + std::env::remove_var("MISSING_NUM"); + } + let result_missing: i32 = get_parsed_or_default("MISSING_NUM", 100); + assert_eq!(result_missing, 100); + + unsafe { + std::env::set_var("INVALID_NUM", "not-a-number"); + } + let result_invalid: i32 = get_parsed_or_default("INVALID_NUM", 100); + assert_eq!( + result_invalid, 100, + "Should return default for unparsable value" + ); + + unsafe { + std::env::remove_var("TEST_NUM"); + std::env::remove_var("INVALID_NUM"); + } + } + + #[test] + #[serial] + fn test_get_bool_variations() { + let test_cases = vec![ + ("true", true), + ("TRUE", true), + ("1", true), + ("yes", true), + ("YES", true), + ("on", true), + ("ON", true), + ("false", false), + ("FALSE", false), + ("0", false), + ("no", false), + ("NO", false), + ("off", false), + ("OFF", false), + ]; + + for (value, expected) in test_cases { + unsafe { + std::env::set_var("TEST_BOOL", value); + } + let result = get_bool("TEST_BOOL", false); + assert_eq!(result, expected, "Failed for value: {value}"); + } + + unsafe { + std::env::remove_var("MISSING_BOOL"); + } + let result = get_bool("MISSING_BOOL", true); + assert!(result); + + unsafe { + std::env::remove_var("TEST_BOOL"); + } + } + + #[test] + #[serial] + fn test_get_bool_invalid_value() { + unsafe { + std::env::set_var("TEST_BOOL", "maybe"); + } + let result = get_bool("TEST_BOOL", false); + assert!(!result); // Should use default + + unsafe { + std::env::remove_var("TEST_BOOL"); + } + } + + #[test] + #[serial] + fn test_multiple_types() { + unsafe { + std::env::set_var("TEST_U8", "255"); + std::env::set_var("TEST_I32", "-42"); + std::env::set_var("TEST_F64", "3.15"); + } + + let u8_val: u8 = get_parsed("TEST_U8").unwrap(); + let i32_val: i32 = get_parsed("TEST_I32").unwrap(); + let f64_val: f64 = get_parsed("TEST_F64").unwrap(); + + assert_eq!(u8_val, 255); + assert_eq!(i32_val, -42); + assert!((f64_val - 3.15).abs() < f64::EPSILON); + + unsafe { + std::env::remove_var("TEST_U8"); + std::env::remove_var("TEST_I32"); + std::env::remove_var("TEST_F64"); + } + } +} diff --git a/crates/env/tests/integration.rs b/crates/env/tests/integration.rs new file mode 100644 index 0000000..e12cbed --- /dev/null +++ b/crates/env/tests/integration.rs @@ -0,0 +1,38 @@ +use env::{get_bool, get_or_default, get_parsed, get_parsed_or_default, get_required}; +use serial_test::serial; + +#[test] +#[serial] +fn test_full_workflow() { + unsafe { + std::env::set_var("APP_NAME", "test_app"); + std::env::set_var("PORT", "3000"); + std::env::set_var("DEBUG", "true"); + } + + // Test required + let app_name = get_required("APP_NAME").unwrap(); + assert_eq!(app_name, "test_app"); + + // Test parsed + let port: u16 = get_parsed("PORT").unwrap(); + assert_eq!(port, 3000); + + // Test boolean + let debug = get_bool("DEBUG", false); + assert!(debug); + + // Test default + let host = get_or_default("HOST", "localhost"); + assert_eq!(host, "localhost"); + + // Test parsed with default + let workers: usize = get_parsed_or_default("WORKERS", 4); + assert_eq!(workers, 4); + + unsafe { + std::env::remove_var("APP_NAME"); + std::env::remove_var("PORT"); + std::env::remove_var("DEBUG"); + } +} diff --git a/crates/err/Cargo.toml b/crates/err/Cargo.toml new file mode 100644 index 0000000..a46cc97 --- /dev/null +++ b/crates/err/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "err" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum.workspace = true +serde.workspace = true +serde_json.workspace = true +tracing.workspace = true +surrealdb = "2.3.10" +thiserror = "2.0.17" diff --git a/crates/err/README.md b/crates/err/README.md new file mode 100644 index 0000000..406d4b1 --- /dev/null +++ b/crates/err/README.md @@ -0,0 +1,9 @@ +# `err` Crate + +This crate provides centralized error handling for the application. + +## Features + +- A unified `AppError` enum. +- Domain-specific error types for database, SSH, and environment errors. +- Automatic conversion of errors to Axum responses. diff --git a/crates/err/src/app_error.rs b/crates/err/src/app_error.rs new file mode 100644 index 0000000..d28d0f9 --- /dev/null +++ b/crates/err/src/app_error.rs @@ -0,0 +1,192 @@ +//! Defines the primary `AppError` and its `IntoResponse` implementation. + +use super::domain::{DatabaseError, EnvironmentError, SshError}; +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde_json::json; +use thiserror::Error; +use tracing::error; + +/// The unified error type for the application. +#[derive(Debug, Error)] +pub enum AppError { + /// Database-related errors. + #[error("Database error: {0}")] + Database(#[from] DatabaseError), + + /// SSH connection/operation errors. + #[error("SSH error: {0}")] + Ssh(#[from] SshError), + + /// Environment configuration errors. + #[error("Environment error: {0}")] + Environment(#[from] EnvironmentError), + + /// Generic server errors. + #[error("Server error: {0}")] + ServerError(String), + + /// Server binding errors. + #[error("Bind error: {0}")] + BindError(String), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + // Extract status code and error details + let (status, error_type, message) = self.get_response_parts(); + + // Log the error with appropriate level + match status { + StatusCode::INTERNAL_SERVER_ERROR | StatusCode::SERVICE_UNAVAILABLE => { + error!(error = ?self, status = %status, "Critical error occurred"); + } + _ => { + tracing::warn!(error = ?self, status = %status, "Handled error occurred"); + } + } + + // Create JSON response + let body = Json(json!({ + "status": status.as_u16(), + "error": error_type, + "message": message, + })); + + (status, body).into_response() + } +} + +impl AppError { + /// Returns the appropriate HTTP status code and error details for the client. + #[inline] + fn get_response_parts(&self) -> (StatusCode, &'static str, &'static str) { + match self { + Self::Database(e) => match e { + DatabaseError::ConnectionError(_) => ( + StatusCode::SERVICE_UNAVAILABLE, + "database_connection_error", + "Database service temporarily unavailable", + ), + DatabaseError::QueryError(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + "database_query_error", + "Database query failed", + ), + DatabaseError::AuthenticationError(_) => ( + StatusCode::UNAUTHORIZED, + "database_auth_error", + "Database authentication failed", + ), + DatabaseError::NotFound(_) => ( + StatusCode::NOT_FOUND, + "database_not_found", + "Resource not found", + ), + DatabaseError::ConfigError(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + "database_config_error", + "Database configuration error", + ), + }, + + Self::Ssh(e) => match e { + SshError::ConnectionFailed(_) => ( + StatusCode::SERVICE_UNAVAILABLE, + "ssh_connection_failed", + "SSH connection failed", + ), + SshError::AuthenticationFailed(_) => ( + StatusCode::UNAUTHORIZED, + "ssh_auth_failed", + "SSH authentication failed", + ), + SshError::InternalTaskError(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + "ssh_internal_error", + "SSH operation failed", + ), + SshError::TimeoutError(_) => ( + StatusCode::REQUEST_TIMEOUT, + "ssh_connection_timeout", + "SSH connection timed out", + ), + }, + + Self::Environment(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + "configuration_error", + "Application misconfiguration detected", + ), + + Self::ServerError(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + "server_error", + "Internal server error", + ), + + Self::BindError(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + "bind_error", + "Server startup failed", + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::error::Error; + + #[test] + fn test_error_chain() { + let db_error = DatabaseError::QueryError("Query failed".to_string()); + let app_error: AppError = db_error.into(); + + // Test error source chain + assert!(app_error.source().is_some()); + assert!(matches!(app_error, AppError::Database(_))); + } + + #[test] + fn test_error_display() { + let error = AppError::ServerError("Internal error".to_string()); + assert_eq!(error.to_string(), "Server error: Internal error"); + } + + #[test] + fn test_http_status_codes() { + let tests = vec![ + ( + DatabaseError::NotFound("test".into()), + StatusCode::NOT_FOUND, + ), + ( + DatabaseError::AuthenticationError("test".into()), + StatusCode::UNAUTHORIZED, + ), + ( + DatabaseError::ConnectionError("test".into()), + StatusCode::SERVICE_UNAVAILABLE, + ), + ]; + + for (db_error, expected_status) in tests { + let app_error = AppError::Database(db_error); + let (status, _, _) = app_error.get_response_parts(); + assert_eq!(status, expected_status); + } + } + + #[test] + fn test_automatic_conversion() { + // Test #[from] attribute works + let _: AppError = DatabaseError::NotFound("test".into()).into(); + let _: AppError = SshError::TimeoutError("test".into()).into(); + let _: AppError = EnvironmentError::NotFoundError("test".into()).into(); + } +} diff --git a/crates/err/src/domain/dbs.rs b/crates/err/src/domain/dbs.rs new file mode 100644 index 0000000..0d1a401 --- /dev/null +++ b/crates/err/src/domain/dbs.rs @@ -0,0 +1,71 @@ +//! # Database Errors +//! +//! Defines the `DatabaseError` enum, which represents errors related to +//! database operations. + +use thiserror::Error; + +/// Database-specific errors. +#[derive(Debug, Error)] +pub enum DatabaseError { + /// A failure to connect to the database. + #[error("Database connection error: {0}")] + ConnectionError(String), + + /// An error executing a database query. + #[error("Database query error: {0}")] + QueryError(String), + + /// A database authentication failure. + #[error("Database authentication error: {0}")] + AuthenticationError(String), + + /// A requested resource was not found in the database. + #[error("Resource not found: {0}")] + NotFound(String), + + /// A database configuration error. + #[error("Database configuration error: {0}")] + ConfigError(String), +} + +// Conversion from third-party errors +impl From for DatabaseError { + fn from(err: surrealdb::Error) -> Self { + // Map specific surrealdb errors to our error types + Self::QueryError(err.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::AppError; + use axum::response::IntoResponse; + + #[test] + fn test_database_error_display() { + let error = DatabaseError::ConnectionError("Connection failed".to_string()); + assert_eq!( + error.to_string(), + "Database connection error: Connection failed" + ); + } + + #[test] + fn test_error_conversion_chain() { + // Test: DatabaseError -> AppError -> Response + let db_error = DatabaseError::NotFound("user".to_string()); + let app_error: AppError = db_error.into(); + let response = app_error.into_response(); + assert_eq!(response.status(), 404); + } + + #[test] + fn test_surrealdb_error_conversion() { + // Test external error conversion + let surreal_err = surrealdb::Error::Db(surrealdb::error::Db::QueryNotExecuted); + let db_error: DatabaseError = surreal_err.into(); + assert!(matches!(db_error, DatabaseError::QueryError(_))); + } +} diff --git a/crates/err/src/domain/env.rs b/crates/err/src/domain/env.rs new file mode 100644 index 0000000..bf9f964 --- /dev/null +++ b/crates/err/src/domain/env.rs @@ -0,0 +1,68 @@ +//! # Environment Errors +//! +//! Defines the `EnvironmentError` enum, which represents errors related to +//! environment variable loading and parsing. + +use std::fmt; +use thiserror::Error; + +/// Environment configuration errors. +#[derive(Error)] +pub enum EnvironmentError { + /// An environment variable was not found. + #[error("Environment variable '{0}' is not set")] + NotFoundError(String), + + /// An environment variable could not be parsed. + #[error("Failed to parse '{key}' as {type_name}")] + Parse { + /// The environment variable key. + key: String, + /// The value that failed to parse. + value: String, + /// The expected type name. + type_name: &'static str, + }, +} + +impl fmt::Debug for EnvironmentError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotFoundError(key) => f.debug_tuple("NotFoundError").field(key).finish(), + Self::Parse { + key, + value: _, + type_name, + } => f + .debug_struct("Parse") + .field("key", key) + .field("value", &"[REDACTED]") + .field("type_name", type_name) + .finish(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_env_error_display() { + let error = EnvironmentError::NotFoundError("DATABASE_URL".to_string()); + assert_eq!( + error.to_string(), + "Environment variable 'DATABASE_URL' is not set" + ); + } + + #[test] + fn test_parse_error_display() { + let error = EnvironmentError::Parse { + key: "PORT".to_string(), + value: "invalid".to_string(), + type_name: "u16", + }; + assert_eq!(error.to_string(), "Failed to parse 'PORT' as u16"); + } +} diff --git a/crates/err/src/domain/mod.rs b/crates/err/src/domain/mod.rs new file mode 100644 index 0000000..a95fe18 --- /dev/null +++ b/crates/err/src/domain/mod.rs @@ -0,0 +1,12 @@ +//! # Domain-Specific Errors +//! +//! This module aggregates and re-exports error types from different application +//! domains, such as database, environment, and SSH. + +mod dbs; +mod env; +mod ssh; + +pub use dbs::DatabaseError; +pub use env::EnvironmentError; +pub use ssh::SshError; diff --git a/crates/err/src/domain/ssh.rs b/crates/err/src/domain/ssh.rs new file mode 100644 index 0000000..13d1c24 --- /dev/null +++ b/crates/err/src/domain/ssh.rs @@ -0,0 +1,37 @@ +//! # SSH Errors +//! +//! Defines the `SshError` enum, which represents errors related to SSH +//! operations. + +use thiserror::Error; + +/// SSH-specific errors. +#[derive(Debug, Error)] +pub enum SshError { + /// An SSH connection failed to establish. + #[error("SSH connection failed: {0}")] + ConnectionFailed(String), + + /// SSH authentication failed. + #[error("SSH authentication failed: {0}")] + AuthenticationFailed(String), + + /// An error occurred during an SSH task. + #[error("Internal SSH task error: {0}")] + InternalTaskError(String), + + /// An SSH operation timed out. + #[error("SSH operation timed out: {0}")] + TimeoutError(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ssh_error_display() { + let error = SshError::ConnectionFailed("timeout".to_string()); + assert_eq!(error.to_string(), "SSH connection failed: timeout"); + } +} diff --git a/crates/err/src/lib.rs b/crates/err/src/lib.rs new file mode 100644 index 0000000..0db5f71 --- /dev/null +++ b/crates/err/src/lib.rs @@ -0,0 +1,14 @@ +//! # Application Error Handling +//! +//! This crate centralizes all application-specific errors. It provides a unified +//! `AppError` type and a consistent way to handle errors across the application. + +mod app_error; +mod domain; + +// Re-export main types +pub use app_error::AppError; +pub use domain::{DatabaseError, EnvironmentError, SshError}; + +/// A specialized `Result` for application-wide use. +pub type AppResult = std::result::Result; diff --git a/crates/err/tests/integration.rs b/crates/err/tests/integration.rs new file mode 100644 index 0000000..9dd9a87 --- /dev/null +++ b/crates/err/tests/integration.rs @@ -0,0 +1,86 @@ +use axum::response::IntoResponse; +use err::{AppError, AppResult, DatabaseError, EnvironmentError, SshError}; + +#[test] +fn test_result_type_alias() { + fn example_function(should_fail: bool) -> AppResult { + if should_fail { + Err(DatabaseError::NotFound("test".into()).into()) + } else { + Ok("success".to_string()) + } + } + + let result = example_function(false); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "success"); + + let error_result = example_function(true); + assert!(error_result.is_err()); +} + +#[test] +fn test_error_propagation_with_question_mark() { + fn inner_function() -> AppResult<()> { + Err(DatabaseError::NotFound("user".into()).into()) + } + + fn outer_function() -> AppResult { + inner_function()?; // Error propagates automatically + Ok("success".to_string()) + } + + let result = outer_function(); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + AppError::Database(DatabaseError::NotFound(_)) + )); +} + +#[test] +fn test_all_domain_error_conversions() { + // Database error conversion + let db_err: AppError = DatabaseError::ConnectionError("test".into()).into(); + assert!(matches!(db_err, AppError::Database(_))); + + // SSH error conversion + let ssh_err: AppError = SshError::TimeoutError("test".into()).into(); + assert!(matches!(ssh_err, AppError::Ssh(_))); + + // Environment error conversion + let env_err: AppError = EnvironmentError::NotFoundError("test".into()).into(); + assert!(matches!(env_err, AppError::Environment(_))); +} + +#[test] +fn test_http_response_mapping() { + let test_cases = vec![ + ( + AppError::Database(DatabaseError::NotFound("test".into())), + 404, + ), + ( + AppError::Database(DatabaseError::AuthenticationError("test".into())), + 401, + ), + (AppError::Ssh(SshError::TimeoutError("test".into())), 408), + ( + AppError::Environment(EnvironmentError::NotFoundError("test".into())), + 500, + ), + ]; + + for (error, expected_status) in test_cases { + let response = error.into_response(); + assert_eq!(response.status().as_u16(), expected_status); + } +} + +#[test] +fn test_error_source_chain() { + use std::error::Error; + let db_error = DatabaseError::QueryError("SQL error".to_string()); + let app_error: AppError = db_error.into(); + assert!(app_error.source().is_some()); +} diff --git a/crates/hlt/Cargo.toml b/crates/hlt/Cargo.toml new file mode 100644 index 0000000..a561262 --- /dev/null +++ b/crates/hlt/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "hlt" +version = "0.1.0" +edition = "2024" + +[dependencies] +# Workspace dependencies +axum.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tracing.workspace = true + +# Additional dependencies +async-trait = "0.1.89" +chrono.workspace = true +futures = "0.3.31" + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "time"] } diff --git a/crates/hlt/README.md b/crates/hlt/README.md new file mode 100644 index 0000000..27648d8 --- /dev/null +++ b/crates/hlt/README.md @@ -0,0 +1,10 @@ +# `hlt` Crate + +A lightweight and extensible health check framework for Axum applications. + +## Features + +- Implement the `HealthCheck` trait to create custom health checks. +- Register checkers with the `HealthRegistry`. +- Run checks concurrently with timeouts. +- Expose results via an Axum handler. diff --git a/crates/hlt/src/lib.rs b/crates/hlt/src/lib.rs new file mode 100644 index 0000000..cf9217b --- /dev/null +++ b/crates/hlt/src/lib.rs @@ -0,0 +1,12 @@ +//! # Health Check Framework +//! +//! A lightweight and extensible health check framework for Axum applications. + +mod models; +mod registry; +mod traits; + +// Public API exports (NO handler export!) +pub use models::{ComponentHealth, ComponentHealthBuilder, HealthStatus, SystemHealthResponse}; +pub use registry::HealthRegistry; +pub use traits::HealthCheck; diff --git a/crates/hlt/src/models.rs b/crates/hlt/src/models.rs new file mode 100644 index 0000000..161cde3 --- /dev/null +++ b/crates/hlt/src/models.rs @@ -0,0 +1,339 @@ +//! # Health Check Models +//! +//! Defines the data structures used for health checking. + +use serde::Serialize; + +/// The health status of a component. +#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum HealthStatus { + /// The component is healthy. + Healthy, + /// The component is in a degraded state. + Degraded, + /// The component is unhealthy. + Unhealthy, +} + +/// The health of a single component. +#[derive(Serialize, Debug, Clone)] +pub struct ComponentHealth { + /// The name of the component. + pub name: String, + /// The health status of the component. + pub status: HealthStatus, + /// An optional message with more details. + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + /// Optional latency in milliseconds for the health check. + /// Typical ranges: + /// - < 50ms: Excellent + /// - 50-100ms: Good + /// - 100-200ms: Acceptable + /// - > 200ms: Should trigger degraded status + #[serde(skip_serializing_if = "Option::is_none")] + pub latency_ms: Option, +} + +impl ComponentHealth { + /// Creates a healthy component status with optional message and latency. + #[must_use] + pub fn healthy( + name: impl Into, + message: Option>, + latency_ms: Option, + ) -> Self { + Self { + name: name.into(), + status: HealthStatus::Healthy, + message: message.map(Into::into), + latency_ms, + } + } + + /// Creates a degraded component status with message and optional latency. + #[must_use] + pub fn degraded( + name: impl Into, + message: impl Into, + latency_ms: Option, + ) -> Self { + Self { + name: name.into(), + status: HealthStatus::Degraded, + message: Some(message.into()), + latency_ms, + } + } + + /// Creates an unhealthy component status with message and optional latency. + #[must_use] + pub fn unhealthy( + name: impl Into, + message: impl Into, + latency_ms: Option, + ) -> Self { + Self { + name: name.into(), + status: HealthStatus::Unhealthy, + message: Some(message.into()), + latency_ms, + } + } + + /// Creates a builder for constructing `ComponentHealth` with a fluent API. + #[must_use] + pub fn builder(name: impl Into) -> ComponentHealthBuilder { + ComponentHealthBuilder::new(name) + } +} + +/// Builder for constructing `ComponentHealth` instances with a fluent API. +#[derive(Debug)] +pub struct ComponentHealthBuilder { + name: String, + status: HealthStatus, + message: Option, + latency_ms: Option, +} + +impl ComponentHealthBuilder { + /// Creates a new builder with the given component name. + /// The default status is `Healthy` + #[must_use] + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + status: HealthStatus::Healthy, + message: None, + latency_ms: None, + } + } + + /// Sets the health status of the component. + #[must_use] + pub fn status(mut self, status: HealthStatus) -> Self { + self.status = status; + self + } + + // Sets an optional message with more details. + #[must_use] + pub fn message(mut self, message: impl Into) -> Self { + self.message = Some(message.into()); + self + } + + /// Sets the latency in milliseconds for the health check. + #[must_use] + pub fn latency_ms(mut self, latency: u64) -> Self { + self.latency_ms = Some(latency); + self + } + + /// Builds and returns the `ComponentHealth` instance. + #[must_use] + pub fn build(self) -> ComponentHealth { + ComponentHealth { + name: self.name, + status: self.status, + message: self.message, + latency_ms: self.latency_ms, + } + } +} + +/// The overall health response for the system. +#[derive(Serialize, Debug)] +pub struct SystemHealthResponse { + /// The overall system health status. + pub status: HealthStatus, + /// The health of individual components. + pub components: Vec, + /// The timestamp of the health check. + pub timestamp: i64, +} + +impl SystemHealthResponse { + /// Creates a new system health response. + #[must_use] + pub fn new(components: Vec) -> Self { + let status = Self::aggregate_status(&components); + let timestamp = chrono::Utc::now().timestamp(); + + Self { + status, + components, + timestamp, + } + } + + /// Aggregates component statuses into a system-wide status. + fn aggregate_status(components: &[ComponentHealth]) -> HealthStatus { + if components + .iter() + .any(|c| matches!(c.status, HealthStatus::Unhealthy)) + { + HealthStatus::Unhealthy + } else if components + .iter() + .any(|c| matches!(c.status, HealthStatus::Degraded)) + { + HealthStatus::Degraded + } else { + HealthStatus::Healthy + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_component_health_constructors() { + let healthy = ComponentHealth::healthy("Database", None::, Some(123)); + assert_eq!(healthy.name, "Database"); + assert_eq!(healthy.status, HealthStatus::Healthy); + assert!(healthy.message.is_none()); + assert_eq!(healthy.latency_ms, Some(123)); + + let degraded = ComponentHealth::degraded("Cache", "High latency", Some(321)); + assert_eq!(degraded.name, "Cache"); + assert_eq!(degraded.status, HealthStatus::Degraded); + assert_eq!(degraded.message, Some("High latency".to_string())); + assert_eq!(degraded.latency_ms, Some(321)); + + let unhealthy = ComponentHealth::unhealthy("API", "Connection failed", None); + assert_eq!(unhealthy.name, "API"); + assert_eq!(unhealthy.status, HealthStatus::Unhealthy); + assert_eq!(unhealthy.message, Some("Connection failed".to_string())); + assert!(unhealthy.latency_ms.is_none()); + } + + #[test] + fn test_builder_pattern_basic() { + let health = ComponentHealth::builder("Database") + .status(HealthStatus::Healthy) + .latency_ms(50) + .build(); + + assert_eq!(health.name, "Database"); + assert_eq!(health.status, HealthStatus::Healthy); + assert!(health.message.is_none()); + assert_eq!(health.latency_ms, Some(50)); + } + + #[test] + fn test_builder_pattern_with_message() { + let health = ComponentHealth::builder("Cache") + .status(HealthStatus::Degraded) + .message("High latency detected") + .latency_ms(250) + .build(); + + assert_eq!(health.name, "Cache"); + assert_eq!(health.status, HealthStatus::Degraded); + assert_eq!(health.message, Some("High latency detected".to_string())); + assert_eq!(health.latency_ms, Some(250)); + } + + #[test] + fn test_builder_pattern_minimal() { + let health = ComponentHealth::builder("API").build(); + + assert_eq!(health.name, "API"); + assert_eq!(health.status, HealthStatus::Healthy); + assert!(health.message.is_none()); + assert!(health.latency_ms.is_none()); + } + + #[test] + fn test_builder_pattern_unhealthy() { + let health = ComponentHealth::builder("Database") + .status(HealthStatus::Unhealthy) + .message("Connection refused") + .latency_ms(5000) + .build(); + + assert_eq!(health.name, "Database"); + assert_eq!(health.status, HealthStatus::Unhealthy); + assert_eq!(health.message, Some("Connection refused".to_string())); + assert_eq!(health.latency_ms, Some(5000)); + } + + #[test] + fn test_system_health_aggregation_all_healthy() { + let components = vec![ + ComponentHealth::healthy("DB", None::, None), + ComponentHealth::healthy("Cache", Some("All good"), Some(50)), + ]; + + let response = SystemHealthResponse::new(components); + assert_eq!(response.status, HealthStatus::Healthy); + assert_eq!(response.components.len(), 2); + } + + #[test] + fn test_system_health_aggregation_with_degraded() { + let components = vec![ + ComponentHealth::healthy("DB", None::, None), + ComponentHealth::degraded("Cache", "Slow", None), + ]; + + let response = SystemHealthResponse::new(components); + assert_eq!(response.status, HealthStatus::Degraded); + } + + #[test] + fn test_system_health_aggregation_with_unhealthy() { + let components = vec![ + ComponentHealth::healthy("DB", None::, None), + ComponentHealth::degraded("Cache", "Slow", None), + ComponentHealth::unhealthy("API", "Down", None), + ]; + + let response = SystemHealthResponse::new(components); + assert_eq!(response.status, HealthStatus::Unhealthy); + } + + #[test] + fn test_system_health_timestamp() { + let components = vec![ComponentHealth::healthy("DB", None::, None)]; + let response = SystemHealthResponse::new(components); + + let now = chrono::Utc::now().timestamp(); + assert!((response.timestamp - now).abs() <= 1); + } + + #[test] + fn test_health_status_serialization() { + let json = serde_json::to_string(&HealthStatus::Healthy).unwrap(); + assert_eq!(json, "\"healthy\""); + + let json = serde_json::to_string(&HealthStatus::Degraded).unwrap(); + assert_eq!(json, "\"degraded\""); + + let json = serde_json::to_string(&HealthStatus::Unhealthy).unwrap(); + assert_eq!(json, "\"unhealthy\""); + } + + #[test] + fn test_component_serialization_with_latency_and_message() { + let comp = ComponentHealth::healthy("DB", Some("OK".to_string()), Some(100)); + let json = serde_json::to_string(&comp).unwrap(); + assert!(json.contains(r#""message":"OK""#)); + assert!(json.contains(r#""latency_ms":100"#)); + + let comp = ComponentHealth::degraded("Cache", "Slow cache", None); + let json = serde_json::to_string(&comp).unwrap(); + assert!(json.contains(r#""message":"Slow cache""#)); + assert!(!json.contains("latency_ms")); + + let comp = ComponentHealth::unhealthy("API", "Down", Some(500)); + let json = serde_json::to_string(&comp).unwrap(); + assert!(json.contains(r#""latency_ms":500"#)); + assert!(json.contains(r#""message":"Down""#)); + } +} diff --git a/crates/hlt/src/registry.rs b/crates/hlt/src/registry.rs new file mode 100644 index 0000000..4b1b695 --- /dev/null +++ b/crates/hlt/src/registry.rs @@ -0,0 +1,251 @@ +//! # Health Check Registry +//! +//! Manages and executes a collection of health checks. + +use crate::{ComponentHealth, HealthCheck, SystemHealthResponse}; +use futures::future::join_all; +use tokio::time::timeout; +use tracing::{debug, instrument, warn}; + +/// A registry for managing and executing health checks. +#[derive(Default)] +pub struct HealthRegistry { + checkers: Vec>, +} + +impl HealthRegistry { + /// Creates a new, empty health registry. + #[must_use] + pub fn new() -> Self { + Self { + checkers: Vec::new(), + } + } + + /// Registers a new health checker. + pub fn register(&mut self, checker: Box) { + self.checkers.push(checker); + } + + /// Executes all registered health checks concurrently. + #[instrument(name = "health_check_all", skip(self))] + pub async fn check_all(&self) -> SystemHealthResponse { + debug!( + checker_count = self.checkers.len(), + "Executing health checks" + ); + + let check_futures = self.checkers.iter().map(|checker| { + let component_name = checker.name().to_string(); + let timeout_duration = checker.timeout(); + async move { + timeout(timeout_duration, checker.check()) + .await + .unwrap_or_else(|_| { + warn!( + component = %component_name, + timeout_secs = timeout_duration.as_secs(), + "Health check timed out" + ); + ComponentHealth::unhealthy( + component_name, + format!("Health check timed out after {timeout_duration:?}"), + None, + ) + }) + } + }); + + let components: Vec = join_all(check_futures).await; + + debug!( + component_count = components.len(), + "Health checks completed" + ); + + SystemHealthResponse::new(components) + } + + /// Returns the number of registered health checkers. + #[must_use] + pub fn count(&self) -> usize { + self.checkers.len() + } + + /// Checks if the registry is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.checkers.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::HealthStatus; + use std::time::Duration; + + struct MockHealthChecker { + name: String, + status: HealthStatus, + timeout_duration: Duration, + } + + #[async_trait::async_trait] + impl HealthCheck for MockHealthChecker { + fn name(&self) -> &str { + &self.name + } + + async fn check(&self) -> ComponentHealth { + ComponentHealth { + name: self.name.clone(), + status: self.status.clone(), + message: None, + latency_ms: None, + } + } + + fn timeout(&self) -> Duration { + self.timeout_duration + } + } + + #[test] + fn test_registry_new() { + let registry = HealthRegistry::new(); + assert_eq!(registry.count(), 0); + assert!(registry.is_empty()); + } + + #[test] + fn test_registry_register() { + let mut registry = HealthRegistry::new(); + + registry.register(Box::new(MockHealthChecker { + name: "Test1".to_string(), + status: HealthStatus::Healthy, + timeout_duration: Duration::from_secs(5), + })); + + assert_eq!(registry.count(), 1); + assert!(!registry.is_empty()); + + registry.register(Box::new(MockHealthChecker { + name: "Test2".to_string(), + status: HealthStatus::Degraded, + timeout_duration: Duration::from_secs(3), + })); + + assert_eq!(registry.count(), 2); + } + + #[tokio::test] + async fn test_registry_check_all_empty() { + let registry = HealthRegistry::new(); + let response = registry.check_all().await; + + assert_eq!(response.status, HealthStatus::Healthy); + assert_eq!(response.components.len(), 0); + } + + #[tokio::test] + async fn test_registry_check_all_healthy() { + let mut registry = HealthRegistry::new(); + + registry.register(Box::new(MockHealthChecker { + name: "DB".to_string(), + status: HealthStatus::Healthy, + timeout_duration: Duration::from_secs(5), + })); + + registry.register(Box::new(MockHealthChecker { + name: "Cache".to_string(), + status: HealthStatus::Healthy, + timeout_duration: Duration::from_secs(3), + })); + + let response = registry.check_all().await; + + assert_eq!(response.status, HealthStatus::Healthy); + assert_eq!(response.components.len(), 2); + } + + #[tokio::test] + async fn test_registry_check_all_degraded() { + let mut registry = HealthRegistry::new(); + + registry.register(Box::new(MockHealthChecker { + name: "DB".to_string(), + status: HealthStatus::Healthy, + timeout_duration: Duration::from_secs(5), + })); + + registry.register(Box::new(MockHealthChecker { + name: "Cache".to_string(), + status: HealthStatus::Degraded, + timeout_duration: Duration::from_secs(3), + })); + + let response = registry.check_all().await; + + assert_eq!(response.status, HealthStatus::Degraded); + assert_eq!(response.components.len(), 2); + } + + #[tokio::test] + async fn test_registry_check_all_unhealthy() { + let mut registry = HealthRegistry::new(); + + registry.register(Box::new(MockHealthChecker { + name: "DB".to_string(), + status: HealthStatus::Unhealthy, + timeout_duration: Duration::from_secs(5), + })); + + registry.register(Box::new(MockHealthChecker { + name: "Cache".to_string(), + status: HealthStatus::Healthy, + timeout_duration: Duration::from_secs(3), + })); + + let response = registry.check_all().await; + + assert_eq!(response.status, HealthStatus::Unhealthy); + assert_eq!(response.components.len(), 2); + } + + #[tokio::test] + async fn test_health_check_timeout() { + struct SlowChecker; + + #[async_trait::async_trait] + impl HealthCheck for SlowChecker { + fn name(&self) -> &'static str { + "SlowDB" + } + + async fn check(&self) -> ComponentHealth { + tokio::time::sleep(Duration::from_secs(10)).await; + ComponentHealth::healthy("SlowDB", None::, None) + } + + fn timeout(&self) -> Duration { + Duration::from_millis(100) // Very short timeout + } + } + + let mut registry = HealthRegistry::new(); + registry.register(Box::new(SlowChecker)); + + let response = registry.check_all().await; + + assert_eq!(response.status, HealthStatus::Unhealthy); + assert_eq!(response.components.len(), 1); + + let component = &response.components[0]; + assert_eq!(component.name, "SlowDB"); // Now correctly identifies the component! + assert_eq!(component.status, HealthStatus::Unhealthy); + assert!(component.message.as_ref().unwrap().contains("timed out")); + } +} diff --git a/crates/hlt/src/traits.rs b/crates/hlt/src/traits.rs new file mode 100644 index 0000000..6b3e37a --- /dev/null +++ b/crates/hlt/src/traits.rs @@ -0,0 +1,116 @@ +//! # Health Check Trait +//! +//! Defines the `HealthCheck` trait, which is the core of the health check +//! framework. + +use crate::ComponentHealth; +use std::time::Duration; + +/// A trait for implementing asynchronous health checks. +/// +/// # Example +/// +/// ``` +/// use hlt::{ComponentHealth, HealthCheck, HealthStatus}; +/// use tokio::time::Duration; +/// +/// struct MyServiceChecker; +/// +/// #[async_trait::async_trait] +/// impl HealthCheck for MyServiceChecker { +/// fn name(&self) -> &'static str { // ✅ Add this +/// "MyService" +/// } +/// +/// async fn check(&self) -> ComponentHealth { +/// // Your health check logic here +/// ComponentHealth::builder("MyService") +/// .status(HealthStatus::Healthy) +/// .build() +/// } +/// +/// fn timeout(&self) -> Duration { +/// Duration::from_secs(3) +/// } +/// } +/// ``` +#[async_trait::async_trait] +pub trait HealthCheck: Send + Sync { + /// Returns the name of this health check component. + /// + /// This name will be used to identify the component in health reports, + /// especially when timeouts occur. + fn name(&self) -> &str; + + /// Performs the health check and returns the component's health status. + async fn check(&self) -> ComponentHealth; + + /// Returns the timeout duration for this health check. + fn timeout(&self) -> Duration; +} + +#[cfg(test)] +mod tests { + use super::*; + + struct MockHealthy; + + #[async_trait::async_trait] + impl HealthCheck for MockHealthy { + fn name(&self) -> &'static str { + "Mock" + } + + async fn check(&self) -> ComponentHealth { + ComponentHealth::healthy("Mock", None::, None) + } + + fn timeout(&self) -> Duration { + Duration::from_secs(5) + } + } + + struct MockUnhealthy; + + #[async_trait::async_trait] + impl HealthCheck for MockUnhealthy { + fn name(&self) -> &'static str { + "Mock" + } + + async fn check(&self) -> ComponentHealth { + ComponentHealth::unhealthy("Mock", "Test failure", None) + } + + fn timeout(&self) -> Duration { + Duration::from_secs(3) + } + } + + #[tokio::test] + async fn test_health_check_trait_healthy() { + let checker = MockHealthy; + assert_eq!(checker.name(), "Mock"); + let result = checker.check().await; + assert_eq!(result.name, "Mock"); + assert_eq!(result.status, crate::HealthStatus::Healthy); + assert_eq!(checker.timeout(), Duration::from_secs(5)); + } + + #[tokio::test] + async fn test_health_check_trait_unhealthy() { + let checker = MockUnhealthy; + assert_eq!(checker.name(), "Mock"); + let result = checker.check().await; + assert_eq!(result.name, "Mock"); + assert_eq!(result.status, crate::HealthStatus::Unhealthy); + assert_eq!(result.message, Some("Test failure".to_string())); + assert_eq!(checker.timeout(), Duration::from_secs(3)); + } + + #[tokio::test] + async fn test_health_check_is_send_sync() { + fn assert_send_sync() {} + assert_send_sync::>(); + } +} diff --git a/crates/hlt/tests/integration.rs b/crates/hlt/tests/integration.rs new file mode 100644 index 0000000..150d618 --- /dev/null +++ b/crates/hlt/tests/integration.rs @@ -0,0 +1,332 @@ +use hlt::{ComponentHealth, HealthCheck, HealthRegistry, HealthStatus}; +use std::sync::Arc; +use tokio::time::{Duration, sleep}; + +struct DatabaseHealth { + should_fail: bool, +} + +#[async_trait::async_trait] +impl HealthCheck for DatabaseHealth { + fn name(&self) -> &'static str { + "Database" + } + + async fn check(&self) -> ComponentHealth { + sleep(Duration::from_millis(10)).await; + if self.should_fail { + ComponentHealth::unhealthy("Database", "Connection timeout", None) + } else { + ComponentHealth::healthy("Database", None::, None) + } + } + + fn timeout(&self) -> Duration { + Duration::from_secs(5) + } +} + +struct CacheHealth { + latency_ms: u64, +} + +#[async_trait::async_trait] +impl HealthCheck for CacheHealth { + fn name(&self) -> &'static str { + "Cache" + } + + async fn check(&self) -> ComponentHealth { + sleep(Duration::from_millis(5)).await; + if self.latency_ms > 100 { + ComponentHealth::degraded( + "Cache", + format!("High latency: {}ms", self.latency_ms), + Some(self.latency_ms), + ) + } else { + ComponentHealth::healthy("Cache", None::, Some(self.latency_ms)) + } + } + + fn timeout(&self) -> Duration { + Duration::from_secs(3) + } +} + +#[tokio::test] +async fn test_full_health_check_workflow() { + let mut registry = HealthRegistry::new(); + registry.register(Box::new(DatabaseHealth { should_fail: false })); + registry.register(Box::new(CacheHealth { latency_ms: 50 })); + + let response = registry.check_all().await; + + assert_eq!(response.status, HealthStatus::Healthy); + assert_eq!(response.components.len(), 2); + + let names: Vec<&str> = response + .components + .iter() + .map(|c| c.name.as_str()) + .collect(); + assert!(names.contains(&"Database")); + assert!(names.contains(&"Cache")); +} + +#[tokio::test] +async fn test_degraded_system_status() { + let mut registry = HealthRegistry::new(); + registry.register(Box::new(DatabaseHealth { should_fail: false })); + registry.register(Box::new(CacheHealth { latency_ms: 150 })); + + let response = registry.check_all().await; + + assert_eq!(response.status, HealthStatus::Degraded); + assert_eq!(response.components.len(), 2); + + let cache = response + .components + .iter() + .find(|c| c.name == "Cache") + .unwrap(); + assert_eq!(cache.status, HealthStatus::Degraded); + assert!(cache.message.is_some()); +} + +#[tokio::test] +async fn test_unhealthy_system_status() { + let mut registry = HealthRegistry::new(); + registry.register(Box::new(DatabaseHealth { should_fail: true })); + registry.register(Box::new(CacheHealth { latency_ms: 50 })); + + let response = registry.check_all().await; + + assert_eq!(response.status, HealthStatus::Unhealthy); + assert_eq!(response.components.len(), 2); + + let db = response + .components + .iter() + .find(|c| c.name == "Database") + .unwrap(); + assert_eq!(db.status, HealthStatus::Unhealthy); + assert_eq!(db.message, Some("Connection timeout".to_string())); +} + +#[tokio::test] +async fn test_concurrent_health_checks() { + let mut registry = HealthRegistry::new(); + + for _ in 0..10 { + registry.register(Box::new(DatabaseHealth { should_fail: false })); + } + + let start = std::time::Instant::now(); + let response = registry.check_all().await; + let duration = start.elapsed(); + + // Increased threshold for CI stability + assert!(duration < Duration::from_millis(150)); + assert_eq!(response.components.len(), 10); + assert_eq!(response.status, HealthStatus::Healthy); +} + +#[tokio::test] +async fn test_mixed_component_statuses() { + let mut registry = HealthRegistry::new(); + registry.register(Box::new(DatabaseHealth { should_fail: true })); + registry.register(Box::new(CacheHealth { latency_ms: 150 })); + + let response = registry.check_all().await; + + assert_eq!(response.status, HealthStatus::Unhealthy); + assert_eq!(response.components.len(), 2); +} + +#[tokio::test] +async fn test_empty_registry() { + let registry = HealthRegistry::new(); + let response = registry.check_all().await; + + assert_eq!(response.status, HealthStatus::Healthy); + assert_eq!(response.components.len(), 0); +} + +#[tokio::test] +async fn test_response_serialization() { + let mut registry = HealthRegistry::new(); + registry.register(Box::new(DatabaseHealth { should_fail: false })); + + let response = registry.check_all().await; + let json = serde_json::to_string(&response).unwrap(); + + assert!(json.contains("\"status\":\"healthy\"")); + assert!(json.contains("\"components\"")); + assert!(json.contains("\"timestamp\"")); +} + +#[tokio::test] +async fn test_thread_safety() { + let registry = Arc::new({ + let mut reg = HealthRegistry::new(); + reg.register(Box::new(DatabaseHealth { should_fail: false })); + reg + }); + + let mut handles = vec![]; + + for _ in 0..5 { + let reg = Arc::clone(®istry); + let handle = tokio::spawn(async move { + let response = reg.check_all().await; + assert_eq!(response.status, HealthStatus::Healthy); + }); + handles.push(handle); + } + + for handle in handles { + handle.await.unwrap(); + } +} + +#[tokio::test] +async fn test_timeout_enforcement() { + struct TimeoutChecker { + delay: Duration, + timeout_duration: Duration, + } + + #[async_trait::async_trait] + impl HealthCheck for TimeoutChecker { + fn name(&self) -> &'static str { + "TimeoutTest" + } + + async fn check(&self) -> ComponentHealth { + sleep(self.delay).await; + ComponentHealth::healthy("TimeoutTest", None::, None) + } + + fn timeout(&self) -> Duration { + self.timeout_duration + } + } + + let mut registry = HealthRegistry::new(); + registry.register(Box::new(TimeoutChecker { + delay: Duration::from_secs(2), + timeout_duration: Duration::from_millis(100), + })); + + let response = registry.check_all().await; + + assert_eq!(response.status, HealthStatus::Unhealthy); + assert_eq!(response.components.len(), 1); + + let component = &response.components[0]; + assert_eq!(component.name, "TimeoutTest"); // Correctly identified! + assert_eq!(component.status, HealthStatus::Unhealthy); + assert!(component.message.as_ref().unwrap().contains("timed out")); +} + +#[tokio::test] +async fn test_different_timeouts_per_component() { + struct FastChecker; + + #[async_trait::async_trait] + impl HealthCheck for FastChecker { + fn name(&self) -> &'static str { + "Fast" + } + + async fn check(&self) -> ComponentHealth { + sleep(Duration::from_millis(10)).await; + ComponentHealth::healthy("Fast", None::, None) + } + + fn timeout(&self) -> Duration { + Duration::from_millis(50) + } + } + + struct SlowChecker; + + #[async_trait::async_trait] + impl HealthCheck for SlowChecker { + fn name(&self) -> &'static str { + "Slow" + } + + async fn check(&self) -> ComponentHealth { + sleep(Duration::from_millis(100)).await; + ComponentHealth::healthy("Slow", None::, None) + } + + fn timeout(&self) -> Duration { + Duration::from_secs(1) + } + } + + let mut registry = HealthRegistry::new(); + registry.register(Box::new(FastChecker)); + registry.register(Box::new(SlowChecker)); + + let response = registry.check_all().await; + + assert_eq!(response.status, HealthStatus::Healthy); + assert_eq!(response.components.len(), 2); +} + +#[tokio::test] +async fn test_builder_pattern_in_health_check() { + struct BuilderStyleChecker { + latency: u64, + } + + #[async_trait::async_trait] + impl HealthCheck for BuilderStyleChecker { + fn name(&self) -> &'static str { + "BuilderTest" + } + + async fn check(&self) -> ComponentHealth { + sleep(Duration::from_millis(5)).await; + + let mut builder = ComponentHealth::builder("BuilderTest").latency_ms(self.latency); + + if self.latency < 100 { + builder = builder.status(HealthStatus::Healthy); + } else if self.latency < 200 { + builder = builder + .status(HealthStatus::Degraded) + .message(format!("Moderate latency: {}ms", self.latency)); + } else { + builder = builder + .status(HealthStatus::Unhealthy) + .message(format!("High latency: {}ms", self.latency)); + } + + builder.build() + } + + fn timeout(&self) -> Duration { + Duration::from_secs(5) + } + } + + let mut registry = HealthRegistry::new(); + registry.register(Box::new(BuilderStyleChecker { latency: 50 })); + let response = registry.check_all().await; + assert_eq!(response.status, HealthStatus::Healthy); + + let mut registry = HealthRegistry::new(); + registry.register(Box::new(BuilderStyleChecker { latency: 150 })); + let response = registry.check_all().await; + assert_eq!(response.status, HealthStatus::Degraded); + + let mut registry = HealthRegistry::new(); + registry.register(Box::new(BuilderStyleChecker { latency: 250 })); + let response = registry.check_all().await; + assert_eq!(response.status, HealthStatus::Unhealthy); +} diff --git a/crates/log/Cargo.toml b/crates/log/Cargo.toml new file mode 100644 index 0000000..f35f96c --- /dev/null +++ b/crates/log/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "log" +version = "0.1.0" +edition = "2024" + +[dependencies] +tracing.workspace = true +tracing-subscriber.workspace = true +env.workspace = true + +[dev.dependencies] + +[dev-dependencies] +serial_test = "3.2.0" diff --git a/src/sys/log/config.rs b/crates/log/src/config.rs similarity index 83% rename from src/sys/log/config.rs rename to crates/log/src/config.rs index d2c4108..ff610f0 100644 --- a/src/sys/log/config.rs +++ b/crates/log/src/config.rs @@ -1,15 +1,15 @@ -use crate::sys::env; - use super::models::{LogConfig, LogFormat}; impl LogFormat { /// Creates a `LogFormat` from the `LOG_FORMAT` environment variable. + #[must_use] pub fn from_env() -> Self { let format_str = env::get_or_default("LOG_FORMAT", "auto"); match format_str.to_lowercase().as_str() { "json" => Self::Json, "compact" => Self::Compact, + "pretty" => Self::Pretty, // ← ADD THIS LINE _ => { if cfg!(debug_assertions) { Self::Compact @@ -23,6 +23,7 @@ impl LogFormat { impl LogConfig { /// Creates a `LogConfig` from environment variables. + #[must_use] pub fn from_env() -> Self { let format = LogFormat::from_env(); @@ -42,8 +43,10 @@ impl LogConfig { #[cfg(test)] mod tests { use super::*; + use serial_test::serial; #[test] + #[serial] fn test_log_format_from_env_json() { unsafe { std::env::set_var("LOG_FORMAT", "json"); @@ -54,7 +57,9 @@ mod tests { std::env::remove_var("LOG_FORMAT"); } } + #[test] + #[serial] fn test_log_format_from_env_compact() { unsafe { std::env::set_var("LOG_FORMAT", "compact"); @@ -67,6 +72,20 @@ mod tests { } #[test] + #[serial] + fn test_log_format_from_env_pretty() { + unsafe { + std::env::set_var("LOG_FORMAT", "pretty"); + } + let format = LogFormat::from_env(); + assert_eq!(format, LogFormat::Pretty); + unsafe { + std::env::remove_var("LOG_FORMAT"); + } + } + + #[test] + #[serial] fn test_log_format_auto_debug_build() { unsafe { std::env::remove_var("LOG_FORMAT"); @@ -80,6 +99,7 @@ mod tests { } #[test] + #[serial] fn test_log_config_from_env() { unsafe { std::env::set_var("LOG_FORMAT", "json"); diff --git a/src/sys/log/init.rs b/crates/log/src/init.rs similarity index 75% rename from src/sys/log/init.rs rename to crates/log/src/init.rs index 267d117..2b5115d 100644 --- a/src/sys/log/init.rs +++ b/crates/log/src/init.rs @@ -1,12 +1,21 @@ -use super::models::{LogConfig, LogFormat}; +use crate::models::{LogConfig, LogFormat}; +use tracing; use tracing_subscriber::{EnvFilter, fmt}; - -/// Initializes the tracing subscriber for logging. pub fn init_tracing() { let config = LogConfig::from_env(); let env_filter = EnvFilter::try_new(&config.filter).unwrap_or_else(|_| EnvFilter::new("info")); match config.format { + LogFormat::Pretty => { + fmt() + .pretty() + .with_target(true) + .with_line_number(true) + .with_env_filter(env_filter) + .with_file(true) + .with_level(true) + .init(); + } LogFormat::Json => { fmt() .json() diff --git a/crates/log/src/lib.rs b/crates/log/src/lib.rs new file mode 100644 index 0000000..3fa3150 --- /dev/null +++ b/crates/log/src/lib.rs @@ -0,0 +1,5 @@ +pub mod config; +pub mod init; +pub mod models; + +pub use init::init_tracing; diff --git a/src/sys/log/models.rs b/crates/log/src/models.rs similarity index 98% rename from src/sys/log/models.rs rename to crates/log/src/models.rs index c421ccc..718c1b2 100644 --- a/src/sys/log/models.rs +++ b/crates/log/src/models.rs @@ -2,6 +2,7 @@ pub enum LogFormat { Json, Compact, + Pretty, } pub struct LogConfig { pub format: LogFormat, diff --git a/src/dbs/README.md b/src/dbs/README.md index 17e4aca..e69de29 100644 --- a/src/dbs/README.md +++ b/src/dbs/README.md @@ -1,11 +0,0 @@ -# `dbs` Module - -This module provides all database-related functionality. - -## File Structure - -- `mod.rs`: The module's root, which exports the public API. -- `connector.rs`: Handles the connection to the database. -- `error.rs`: Defines the database-specific error types. -- `health.rs`: Implements the health check for the database. -- `models.rs`: Defines the database models and configurations. diff --git a/src/dbs/connector.rs b/src/dbs/connector.rs index 800a824..bf9b550 100644 --- a/src/dbs/connector.rs +++ b/src/dbs/connector.rs @@ -1,12 +1,13 @@ -use super::error::DatabaseError; use super::models::{DbConfig, DbConnection}; -use crate::sys::env; +use err::{DatabaseError, EnvironmentError}; use std::sync::Arc; use surrealdb::opt::auth::Namespace; /// Establishes a connection to the `SurrealDB` database. /// # Errors -/// Returns `DatabaseError::ConnectionError` or `DatabaseError::AuthenticationError` on failure. +/// Returns `DatabaseError::ConnectionError` if the connection to the database fails or if +/// namespace/database selection fails. +/// Returns `DatabaseError::AuthenticationError` if authentication with the provided credentials fails. pub async fn connect(config: &DbConfig) -> Result { let db = surrealdb::engine::any::connect(&config.endpoint) .await @@ -32,22 +33,13 @@ impl DbConfig { /// Creates a database configuration from environment variables. /// # Errors /// Returns `DatabaseError::ConfigError` if any required environment variable is not set. - pub fn from_env() -> Result { + pub fn from_env() -> Result { Ok(Self { - endpoint: env::get_required("DB_ENDPOINT") - .map_err(|e| DatabaseError::ConfigError(e.to_string()))?, - - namespace: env::get_required("DB_NAMESPACE") - .map_err(|e| DatabaseError::ConfigError(e.to_string()))?, - - database: env::get_required("DB_NAME") - .map_err(|e| DatabaseError::ConfigError(e.to_string()))?, - - username: env::get_required("DB_USERNAME") - .map_err(|e| DatabaseError::ConfigError(e.to_string()))?, - - password: env::get_required("DB_PASSWORD") - .map_err(|e| DatabaseError::ConfigError(e.to_string()))?, + endpoint: env::get_required("DB_ENDPOINT")?, // EnvironmentError + namespace: env::get_required("DB_NAMESPACE")?, + database: env::get_required("DB_NAME")?, + username: env::get_required("DB_USERNAME")?, + password: env::get_required("DB_PASSWORD")?, }) } } diff --git a/src/dbs/error.rs b/src/dbs/error.rs deleted file mode 100644 index 31a857f..0000000 --- a/src/dbs/error.rs +++ /dev/null @@ -1,67 +0,0 @@ -use std::fmt; - -#[derive(Debug)] -pub enum DatabaseError { - ConnectionError(String), - QueryError(String), - AuthenticationError(String), - NotFound(String), - ConfigError(String), -} - -impl fmt::Display for DatabaseError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::ConnectionError(msg) => write!(f, "Connection error: {msg}"), - Self::QueryError(msg) => write!(f, "Query error: {msg}"), - Self::AuthenticationError(msg) => write!(f, "Authentication error: {msg}"), - - Self::NotFound(msg) => write!(f, "Not found: {msg}"), - Self::ConfigError(msg) => write!(f, "Configuration error: {msg}"), - } - } -} - -impl std::error::Error for DatabaseError {} - -impl From for DatabaseError { - fn from(err: surrealdb::Error) -> Self { - Self::QueryError(err.to_string()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::err::AppError; - use axum::{http::StatusCode, response::IntoResponse}; - - #[test] - fn test_database_error_display() { - let error = DatabaseError::ConnectionError("Connection failed".to_string()); - assert_eq!(error.to_string(), "Connection error: Connection failed"); - let error = DatabaseError::NotFound("User not found".to_string()); - assert_eq!(error.to_string(), "Not found: User not found"); - } - - #[test] - fn test_database_error_into_response() { - // Test AuthenticationError - let db_error = DatabaseError::AuthenticationError("Invalid credentials".to_string()); - let app_error: AppError = db_error.into(); - let response = app_error.into_response(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - - // Test NotFound error - let db_error = DatabaseError::NotFound("Resource not found".to_string()); - let app_error: AppError = db_error.into(); - let response = app_error.into_response(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - - // Test ConnectionError - let db_error = DatabaseError::ConnectionError("Connection failed".to_string()); - let app_error: AppError = db_error.into(); - let response = app_error.into_response(); - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - } -} diff --git a/src/dbs/health.rs b/src/dbs/health.rs index f4d7a2e..1f3fbea 100644 --- a/src/dbs/health.rs +++ b/src/dbs/health.rs @@ -1,55 +1,64 @@ -use super::models::Database; -use crate::sys::{ - env, - health::{ComponentHealth, HealthCheck, HealthStatus}, -}; -use tokio::time::{Duration, Instant, timeout}; +use crate::dbs::Database; +use hlt::{ComponentHealth, HealthCheck, HealthStatus}; +use tokio::time::{Duration, Instant}; use tracing::{debug, warn}; #[async_trait::async_trait] impl HealthCheck for Database { - /// Performs a health check on the database. + /// Returns the name of this health check component. + fn name(&self) -> &'static str { + "Database" + } + + // We cast u128 to u64 here because health check latencies will never exceed + // u64::MAX milliseconds (~584 million years). This is a safe truncation. + #[allow(clippy::cast_possible_truncation)] async fn check(&self) -> ComponentHealth { let start = Instant::now(); debug!("Performing database health check"); - let timeout_secs = env::get_parsed_or_default("DB_HEALTH_CHECK_TIMEOUT", 5); - let (status, message) = match timeout( - Duration::from_secs(timeout_secs), - self.db.query("RETURN true;"), - ) - .await - { - Ok(Ok(_)) => { - let elapsed = start.elapsed(); - debug!( - latency_ms = elapsed.as_millis(), - "Database health check successful" - ); - ( - HealthStatus::Healthy, - Some(format!("Response time: {}ms", elapsed.as_millis())), - ) - } - Ok(Err(e)) => { - warn!(error = %e, "Database health check failed"); - (HealthStatus::Unhealthy, Some(format!("Query error: {e}"))) - } - Err(_) => { - warn!( - timeout_secs = timeout_secs, - "Database health check timed out" - ); - ( - HealthStatus::Unhealthy, - Some(format!("Health check timeout after {timeout_secs} seconds")), - ) + + match self.db.query("RETURN true;").await { + Ok(_) => { + let elapsed = start.elapsed().as_millis() as u64; + debug!(latency_ms = elapsed, "Database health check successful"); + + // Using builder pattern for cleaner code + ComponentHealth::builder("Database") + .status(HealthStatus::Healthy) + .latency_ms(elapsed) + .build() } - }; + Err(e) => { + let elapsed = start.elapsed().as_millis() as u64; + warn!(error = %e, latency_ms = elapsed, "Database health check failed"); - ComponentHealth { - name: "Database".to_string(), - status, - message, + // Builder pattern makes error cases cleaner + ComponentHealth::builder("Database") + .status(HealthStatus::Unhealthy) + .message(format!("Query error: {e}")) + .latency_ms(elapsed) + .build() + } } } + + fn timeout(&self) -> Duration { + let timeout_secs = env::get_parsed_or_default("DB_HEALTH_CHECK_TIMEOUT", 5); + Duration::from_secs(timeout_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Mock test to verify trait implementation compiles + #[test] + fn test_database_health_check_name() { + // This test ensures the Database type implements HealthCheck correctly + // We can't easily test without a real database connection, but we can + // verify the trait is implemented properly at compile time + fn assert_implements_health_check() {} + assert_implements_health_check::(); + } } diff --git a/src/dbs/mod.rs b/src/dbs/mod.rs index 35900ff..e61db88 100644 --- a/src/dbs/mod.rs +++ b/src/dbs/mod.rs @@ -1,8 +1,7 @@ mod connector; -mod error; mod health; mod models; +// Public API exports pub use connector::connect; -pub use error::DatabaseError; pub use models::{Database, DbConfig, DbConnection}; diff --git a/src/dbs/models.rs b/src/dbs/models.rs index 639ae2d..d1c9f24 100644 --- a/src/dbs/models.rs +++ b/src/dbs/models.rs @@ -32,11 +32,12 @@ impl fmt::Debug for DbConfig { #[cfg(test)] mod tests { use super::*; - use crate::dbs::error::DatabaseError; + use serial_test::serial; use std::env as std_env; - // Helper to set environment variables - fn setup_db_env() { + #[test] + #[serial] + fn test_dbconfig_from_env_success() { unsafe { std_env::set_var("DB_ENDPOINT", "ws://test.com"); std_env::set_var("DB_NAMESPACE", "ns_test"); @@ -44,25 +45,6 @@ mod tests { std_env::set_var("DB_USERNAME", "user_test"); std_env::set_var("DB_PASSWORD", "pass_test"); } - } - - // Helper to clear environment variables - fn clear_db_env() { - unsafe { - std_env::remove_var("DB_ENDPOINT"); - std_env::remove_var("DB_NAMESPACE"); - std_env::remove_var("DB_NAME"); - std_env::remove_var("DB_USERNAME"); - std_env::remove_var("DB_PASSWORD"); - } - } - - #[test] - fn test_dbconfig_from_env_success() { - // Description: Validates that `DbConfig` is correctly created when all - // required environment variables are set. - // Reasoning: This is the happy path for configuration loading. - setup_db_env(); let config = DbConfig::from_env().expect("Should create config successfully"); @@ -72,16 +54,27 @@ mod tests { assert_eq!(config.username, "user_test"); assert_eq!(config.password, "pass_test"); - clear_db_env(); + unsafe { + std_env::remove_var("DB_ENDPOINT"); + std_env::remove_var("DB_NAMESPACE"); + std_env::remove_var("DB_NAME"); + std_env::remove_var("DB_USERNAME"); + std_env::remove_var("DB_PASSWORD"); + } } #[test] + #[serial] fn test_dbconfig_from_env_missing_variable() { - // Description: Validates that `DbConfig::from_env` returns a `ConfigError` - // if a required environment variable is missing. - // Reasoning: Ensures the application provides a clear error message on - // misconfiguration instead of panicking. - clear_db_env(); // Ensure all vars are unset + use err::EnvironmentError; + + unsafe { + std_env::remove_var("DB_ENDPOINT"); + std_env::remove_var("DB_NAMESPACE"); + std_env::remove_var("DB_NAME"); + std_env::remove_var("DB_USERNAME"); + std_env::remove_var("DB_PASSWORD"); + } unsafe { std_env::set_var("DB_ENDPOINT", "ws://test.com"); @@ -94,9 +87,17 @@ mod tests { let result = DbConfig::from_env(); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), DatabaseError::ConfigError(_))); + assert!(matches!( + result.unwrap_err(), + EnvironmentError::NotFoundError(_) + )); - clear_db_env(); + unsafe { + std_env::remove_var("DB_ENDPOINT"); + std_env::remove_var("DB_NAMESPACE"); + std_env::remove_var("DB_USERNAME"); + std_env::remove_var("DB_PASSWORD"); + } } #[test] diff --git a/src/err/README.md b/src/err/README.md deleted file mode 100644 index 2beba76..0000000 --- a/src/err/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# `err` Module - -This module provides application-wide error handling. - -## File Structure - -- `mod.rs`: The module's root, which exports the public API. -- `error.rs`: Defines the main `AppError` enum and its implementations. diff --git a/src/err/error.rs b/src/err/error.rs deleted file mode 100644 index 140b699..0000000 --- a/src/err/error.rs +++ /dev/null @@ -1,202 +0,0 @@ -use crate::dbs::DatabaseError; -use crate::ssh::SshError; -use crate::sys::EnvironmentError; -use axum::{ - Json, - http::StatusCode, - response::{IntoResponse, Response}, -}; -use serde_json::json; -use std::fmt; -use tracing::error; - -#[derive(Debug)] -pub enum AppError { - Database(DatabaseError), - Ssh(SshError), - ServerError(String), - BindError(String), - Environment(EnvironmentError), -} - -impl fmt::Display for AppError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Ssh(e) => write!(f, "SSH error: {e}"), - Self::Database(e) => write!(f, "Database error: {e}"), - Self::Environment(e) => write!(f, "Environment error: {e}"), - Self::ServerError(msg) => write!(f, "Server error: {msg}"), - Self::BindError(msg) => write!(f, "Bind error: {msg}"), - } - } -} - -impl std::error::Error for AppError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - AppError::Database(e) => Some(e), - AppError::Ssh(e) => Some(e), - AppError::Environment(e) => Some(e), - _ => None, - } - } -} - -impl IntoResponse for AppError { - fn into_response(self) -> Response { - let (status, error_type, message) = match &self { - // Database errors - Self::Database(e) => { - error!(error = ?e, "Database error occurred"); - match e { - DatabaseError::ConnectionError(_) => ( - StatusCode::SERVICE_UNAVAILABLE, - "database_connection_error", - "Database service temporarily unavailable", - ), - DatabaseError::QueryError(_) => ( - StatusCode::INTERNAL_SERVER_ERROR, - "database_query_error", - "Database query failed", - ), - DatabaseError::AuthenticationError(_) => ( - StatusCode::UNAUTHORIZED, - "database_auth_error", - "Database authentication failed", - ), - DatabaseError::NotFound(_) => ( - StatusCode::NOT_FOUND, - "database_not_found", - "Resource not found", - ), - DatabaseError::ConfigError(_) => ( - StatusCode::INTERNAL_SERVER_ERROR, - "database_config_error", - "Database configuration error", - ), - } - } - - // SSH errors - Self::Ssh(e) => { - error!(error = ?e, "SSH error occurred"); - match e { - SshError::ConnectionFailed(_) => ( - StatusCode::SERVICE_UNAVAILABLE, - "ssh_connection_failed", - "SSH connection failed", - ), - SshError::AuthenticationFailed(_) => ( - StatusCode::UNAUTHORIZED, - "ssh_auth_failed", - "SSH authentication failed", - ), - SshError::InternalTaskError(_) => ( - StatusCode::INTERNAL_SERVER_ERROR, - "ssh_internal_error", - "SSH operation failed", - ), - SshError::TimeoutError(_) => ( - StatusCode::REQUEST_TIMEOUT, - "ssh_connection_timeout", - "SSH connection timed out", - ), - } - } - - // Environment errors - Self::Environment(e) => { - error!(error = ?e, "Environment configuration error"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "configuration_error", - "Application misconfiguration detected", - ) - } - - // Server errors - Self::ServerError(msg) => { - error!(error = %msg, "Server error"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "server_error", - "Internal server error", - ) - } - - // Bind errors - Self::BindError(msg) => { - error!(error = %msg, "Bind error"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "bind_error", - "Server startup failed", - ) - } - }; - - let body = Json(json!({ - "status":status.as_u16(), - "error": error_type, - "message": message - })); - - (status, body).into_response() - } -} - -// Keep all From implementations for automatic conversion -impl From for AppError { - fn from(err: DatabaseError) -> Self { - Self::Database(err) - } -} - -impl From for AppError { - fn from(err: EnvironmentError) -> Self { - Self::Environment(err) - } -} - -impl From for AppError { - fn from(err: SshError) -> Self { - Self::Ssh(err) - } -} - -impl From for AppError { - fn from(err: std::env::VarError) -> Self { - Self::ServerError(format!("Environment variable error: {err}")) - } -} - -impl From for AppError { - fn from(err: surrealdb::Error) -> Self { - Self::Database(DatabaseError::QueryError(err.to_string())) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_app_error_from_database_error() { - let db_error = DatabaseError::QueryError("Query failed".to_string()); - let app_error: AppError = db_error.into(); - assert!(matches!(app_error, AppError::Database(_))); - } - - #[test] - fn test_app_error_display() { - let error = AppError::ServerError("Server error".to_string()); - assert_eq!(error.to_string(), "Server error: Server error"); - } - - #[test] - fn test_app_error_into_response() { - let error = AppError::ServerError("Internal error".to_string()); - let response = error.into_response(); - assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); - } -} diff --git a/src/err/mod.rs b/src/err/mod.rs deleted file mode 100644 index 84eead3..0000000 --- a/src/err/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod error; -pub use error::AppError; diff --git a/src/lib.rs b/src/lib.rs index 2c3ca12..25ac79f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,8 @@ mod dbs; -mod err; mod rts; mod ssh; mod sys; // Public API exports -pub use err::AppError; pub use rts::{health_handler, root_handler, ssh_handler}; -pub use sys::{init_tracing, initialize}; +pub use sys::initialize; diff --git a/src/main.rs b/src/main.rs index 24d5d9b..9eefcd0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use axum::routing::{get, post}; -use axum_backend::{AppError, health_handler, initialize, root_handler, ssh_handler}; - +use axum_backend::{health_handler, initialize, root_handler, ssh_handler}; +use err::{AppError, AppResult}; use tracing::error; /// Initializes and runs the application. @@ -9,7 +9,7 @@ use tracing::error; /// /// Returns `AppError` if initialization or server execution fails. #[tokio::main] -async fn main() -> Result<(), AppError> { +async fn main() -> AppResult<()> { let (app, state, listener) = initialize().await?; // Add routes to the router diff --git a/src/rts/README.md b/src/rts/README.md index f1179b3..e69de29 100644 --- a/src/rts/README.md +++ b/src/rts/README.md @@ -1,10 +0,0 @@ -# `rts` Module - -This module defines the application's routes. - -## File Structure - -- `mod.rs`: The module's root, which exports the public API. -- `health.rs`: Defines the health check route. -- `root.rs`: Defines the root route. -- `sshconnect.rs`: Defines the SSH connection testing route. diff --git a/src/rts/health.rs b/src/rts/health.rs index e06e9c1..f41249d 100644 --- a/src/rts/health.rs +++ b/src/rts/health.rs @@ -1,40 +1,121 @@ -use crate::sys::{ - config::state::AppState, - health::{ComponentHealth, HealthStatus, SystemHealthResponse}, -}; +use crate::sys::config::state::AppState; use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; -use futures::future::join_all; +use hlt::HealthStatus; use std::sync::Arc; +use tracing::{debug, warn}; -/// Aggregates the health of all system components. +/// Axum handler for health check endpoints. +/// +/// Returns the aggregated health status of all registered components. +/// +/// # HTTP Status Codes +/// +/// - `200 OK` - System is healthy or degraded +/// - `503 Service Unavailable` - System is unhealthy pub async fn health_handler(State(state): State>) -> impl IntoResponse { - let check_futures = state.health_checkers.iter().map(|checker| checker.check()); - - let results: Vec = join_all(check_futures).await; - - let overall_status = if results - .iter() - .any(|r| matches!(r.status, HealthStatus::Unhealthy)) - { - HealthStatus::Unhealthy - } else if results - .iter() - .any(|r| matches!(r.status, HealthStatus::Degraded)) - { - HealthStatus::Degraded - } else { - HealthStatus::Healthy - }; + // Extract the registry from AppState and call check_all + let response = state.health_registry.check_all().await; - let http_status = match overall_status { - HealthStatus::Healthy | HealthStatus::Degraded => StatusCode::OK, - HealthStatus::Unhealthy => StatusCode::SERVICE_UNAVAILABLE, - }; - let response = SystemHealthResponse { - status: overall_status, - components: results, - timestamp: chrono::Utc::now().timestamp(), + let http_status = match response.status { + HealthStatus::Healthy => { + debug!("Health check: all components healthy"); + StatusCode::OK + } + HealthStatus::Degraded => { + warn!("Health check: system degraded"); + StatusCode::OK + } + HealthStatus::Unhealthy => { + warn!("Health check: system unhealthy"); + StatusCode::SERVICE_UNAVAILABLE + } }; (http_status, Json(response)) } + +#[cfg(test)] +mod tests { + use super::*; + use hlt::{ComponentHealth, HealthCheck, HealthRegistry}; + use std::time::Duration; + + struct MockChecker { + status: HealthStatus, + } + + #[async_trait::async_trait] + impl HealthCheck for MockChecker { + fn name(&self) -> &'static str { + "Mock" + } + + async fn check(&self) -> ComponentHealth { + ComponentHealth::builder("Mock") + .status(self.status.clone()) + .build() + } + + fn timeout(&self) -> Duration { + Duration::from_secs(5) + } + } + + #[tokio::test] + async fn test_health_registry_directly() { + let mut registry = HealthRegistry::new(); + registry.register(Box::new(MockChecker { + status: HealthStatus::Healthy, + })); + + let response = registry.check_all().await; + assert_eq!(response.status, HealthStatus::Healthy); + } + + #[tokio::test] + async fn test_health_status_mapping() { + let healthy = HealthStatus::Healthy; + let status = match healthy { + HealthStatus::Healthy | HealthStatus::Degraded => StatusCode::OK, + HealthStatus::Unhealthy => StatusCode::SERVICE_UNAVAILABLE, + }; + assert_eq!(status, StatusCode::OK); + + let unhealthy = HealthStatus::Unhealthy; + let status = match unhealthy { + HealthStatus::Healthy | HealthStatus::Degraded => StatusCode::OK, + HealthStatus::Unhealthy => StatusCode::SERVICE_UNAVAILABLE, + }; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + } + + #[tokio::test] + async fn test_health_registry_with_multiple_statuses() { + let mut registry = HealthRegistry::new(); + + registry.register(Box::new(MockChecker { + status: HealthStatus::Healthy, + })); + + registry.register(Box::new(MockChecker { + status: HealthStatus::Degraded, + })); + + let response = registry.check_all().await; + assert_eq!(response.status, HealthStatus::Degraded); + assert_eq!(response.components.len(), 2); + } + + #[tokio::test] + async fn test_health_registry_with_unhealthy() { + let mut registry = HealthRegistry::new(); + + registry.register(Box::new(MockChecker { + status: HealthStatus::Unhealthy, + })); + + let response = registry.check_all().await; + assert_eq!(response.status, HealthStatus::Unhealthy); + assert_eq!(response.components.len(), 1); + } +} diff --git a/src/rts/sshconnect.rs b/src/rts/sshconnect.rs index a2334fd..66d1cc8 100644 --- a/src/rts/sshconnect.rs +++ b/src/rts/sshconnect.rs @@ -1,6 +1,6 @@ -use crate::err::AppError; use crate::ssh::{ConnectionStatus, SshCredentials, ssh_connect}; use axum::Json; +use err::AppResult; /// Handler for testing an SSH connection. /// @@ -8,7 +8,7 @@ use axum::Json; /// Returns `AppError` if the SSH connection or authentication fails. pub async fn ssh_handler( Json(credentials): Json, -) -> Result, AppError> { +) -> AppResult> { let status = ssh_connect(&credentials).await?; Ok(Json(status)) } diff --git a/src/ssh/connector.rs b/src/ssh/connector.rs index 711f33b..6a7a1d1 100644 --- a/src/ssh/connector.rs +++ b/src/ssh/connector.rs @@ -1,8 +1,5 @@ -use super::{ - error::SshError, - models::{ConnectionStatus, SshCredentials}, -}; -use crate::{err::AppError, sys::env}; +use super::models::{ConnectionStatus, SshCredentials}; +use err::{AppResult, SshError}; use std::{ net::{TcpStream, ToSocketAddrs}, time::Duration, @@ -36,7 +33,8 @@ use tracing::{debug, error, info, instrument}; ssh.port=%credentials.port, ) )] -pub async fn ssh_connect(credentials: &SshCredentials) -> Result { + +pub async fn ssh_connect(credentials: &SshCredentials) -> AppResult { info!("Attempting to establish SSH connection."); // Clone credentials to move them into the blocking thread. diff --git a/src/ssh/error.rs b/src/ssh/error.rs deleted file mode 100644 index 81852fc..0000000 --- a/src/ssh/error.rs +++ /dev/null @@ -1,22 +0,0 @@ -use std::fmt; - -#[derive(Debug)] -pub enum SshError { - ConnectionFailed(String), - AuthenticationFailed(String), - InternalTaskError(String), - TimeoutError(String), -} - -impl fmt::Display for SshError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - Self::ConnectionFailed(msg) => write!(f, "SSH connection failed: {msg}"), - Self::AuthenticationFailed(msg) => write!(f, "SSH authentication failed: {msg}"), - Self::InternalTaskError(msg) => write!(f, "Internal SSH task error: {msg}"), - Self::TimeoutError(msg) => write!(f, "SSH operation timed out: {msg}"), - } - } -} - -impl std::error::Error for SshError {} diff --git a/src/ssh/mod.rs b/src/ssh/mod.rs index 4615376..4c152f6 100644 --- a/src/ssh/mod.rs +++ b/src/ssh/mod.rs @@ -1,7 +1,5 @@ mod connector; -mod error; mod models; pub use connector::ssh_connect; -pub use error::SshError; pub use models::{ConnectionStatus, SshCredentials}; diff --git a/src/sys/config/server.rs b/src/sys/config/server.rs index 6213dd2..478588f 100644 --- a/src/sys/config/server.rs +++ b/src/sys/config/server.rs @@ -1,5 +1,3 @@ -use crate::sys::env; - #[derive(Clone)] pub struct ServerConfig { pub host: String, @@ -26,8 +24,10 @@ impl ServerConfig { #[cfg(test)] mod tests { use super::*; + use serial_test::serial; #[test] + #[serial] fn test_server_config_from_env() { unsafe { std::env::set_var("SERVER_HOST", "127.0.0.1"); @@ -43,6 +43,7 @@ mod tests { } #[test] + #[serial] fn test_server_config_defaults() { unsafe { std::env::remove_var("SERVER_HOST"); diff --git a/src/sys/config/state.rs b/src/sys/config/state.rs index 795e2ca..8ae1e36 100644 --- a/src/sys/config/state.rs +++ b/src/sys/config/state.rs @@ -1,8 +1,9 @@ -use crate::{dbs::DbConnection, sys::health::HealthCheck}; +use crate::dbs::DbConnection; +use hlt::HealthRegistry; use std::sync::Arc; #[derive(Clone)] pub struct AppState { pub db_connection: DbConnection, - pub health_checkers: Arc>>, + pub health_registry: Arc, } diff --git a/src/sys/env/README.md b/src/sys/env/README.md deleted file mode 100644 index d1a0dd7..0000000 --- a/src/sys/env/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# `env` Module - -This module provides utilities for reading and parsing environment variables. - -## File Structure - -- `mod.rs`: The module's root, which exports the public API. -- `error.rs`: Defines the error types for environment variable handling. -- `loader.rs`: Provides functions for loading and parsing environment variables. diff --git a/src/sys/env/error.rs b/src/sys/env/error.rs deleted file mode 100644 index 28177d5..0000000 --- a/src/sys/env/error.rs +++ /dev/null @@ -1,30 +0,0 @@ -use std::fmt; - -#[derive(Debug)] -pub enum EnvironmentError { - NotFoundError(String), - Parse { - key: String, - value: String, - type_name: &'static str, - }, -} - -impl fmt::Display for EnvironmentError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::NotFoundError(key) => { - write!(f, "Environment variable '{key}' is not set") - } - Self::Parse { - key, - value, - type_name, - } => { - write!(f, "Failed to parse '{key}={value}' as {type_name}") - } - } - } -} - -impl std::error::Error for EnvironmentError {} diff --git a/src/sys/env/loader.rs b/src/sys/env/loader.rs deleted file mode 100644 index 5240bb7..0000000 --- a/src/sys/env/loader.rs +++ /dev/null @@ -1,165 +0,0 @@ -use super::error::EnvironmentError; -use std::{env, str::FromStr}; -use tracing::debug; - -/// Retrieves a required environment variable as a String -/// # Errors -/// Returns `EnvironmentError::NotFoundError` if the variable is not set -pub fn get_required(key: &str) -> Result { - env::var(key).map_err(|_| EnvironmentError::NotFoundError(key.to_string())) -} - -/// Retrieves an optional environment variable with a default value -#[must_use] -pub fn get_or_default(key: &str, default: &str) -> String { - env::var(key).unwrap_or_else(|_| { - debug!(key = %key, default = %default, "Using default value for environment variable"); - default.to_string() - }) -} - -/// Retrieves and parses an environment variable. -/// -/// # Errors -/// -/// - `EnvironmentError::NotFoundError` if the variable is not set. -/// - `EnvironmentError::Parse` if the variable cannot be parsed. -#[allow(dead_code)] -pub fn get_parsed(key: &str) -> Result -where - T: FromStr, -{ - let value = get_required(key)?; - - value.parse::().map_err(|_| EnvironmentError::Parse { - key: key.to_string(), - value: value.clone(), - type_name: std::any::type_name::(), - }) -} - -/// Retrieves and parses an environment variable with a default value -#[must_use] -pub fn get_parsed_or_default(key: &str, default: T) -> T -where - T: FromStr + std::fmt::Debug, -{ - env::var(key) - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or_else(|| { - debug!(key = %key, default = ?default, "Using default parsed value for environment variable"); - default - }) -} - -/// Retrieves a boolean environment variable -/// Accepts: true/false, 1/0, yes/no, on/off (case-insensitive) -#[must_use] -#[allow(dead_code)] -pub fn get_bool(key: &str, default: bool) -> bool { - env::var(key) - .ok() - .and_then(|v| match v.to_lowercase().as_str() { - "true" | "1" | "yes" | "on" => Some(true), - "false" | "0" | "no" | "off" => Some(false), - _ => None, - }) - .unwrap_or(default) -} -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_get_required_success() { - unsafe { std::env::set_var("TEST_VAR", "test_value") }; - let result = get_required("TEST_VAR"); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), "test_value"); - unsafe { std::env::remove_var("TEST_VAR") }; - } - - #[test] - fn test_get_required_missing() { - let result = get_required("NONEXISTENT_VAR"); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - EnvironmentError::NotFoundError(_) - )); - } - - #[test] - fn test_get_or_default_existing() { - unsafe { std::env::set_var("TEST_DEFAULT", "actual_value") }; - let result = get_or_default("TEST_DEFAULT", "default_value"); - assert_eq!(result, "actual_value"); - unsafe { std::env::remove_var("TEST_DEFAULT") }; - } - - #[test] - fn test_get_or_default_missing() { - let result = get_or_default("MISSING_VAR", "default_value"); - assert_eq!(result, "default_value"); - } - - #[test] - fn test_get_parsed_success() { - unsafe { std::env::set_var("TEST_PORT", "8080") }; - let result: Result = get_parsed("TEST_PORT"); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), 8080); - unsafe { std::env::remove_var("TEST_PORT") }; - } - - #[test] - fn test_get_parsed_invalid_type() { - unsafe { std::env::set_var("TEST_PORT", "invalid") }; - let result: Result = get_parsed("TEST_PORT"); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - EnvironmentError::Parse { .. } - )); - unsafe { std::env::remove_var("TEST_PORT") }; - } - - #[test] - fn test_get_parsed_or_default() { - unsafe { std::env::set_var("TEST_NUM", "42") }; - let result: i32 = get_parsed_or_default("TEST_NUM", 100); - assert_eq!(result, 42); - unsafe { std::env::remove_var("TEST_NUM") }; - - let result: i32 = get_parsed_or_default("MISSING_NUM", 100); - assert_eq!(result, 100); - } - - #[test] - fn test_get_bool_variations() { - let test_cases = vec![ - ("true", true), - ("TRUE", true), - ("1", true), - ("yes", true), - ("on", true), - ("false", false), - ("FALSE", false), - ("0", false), - ("no", false), - ("off", false), - ]; - - for (value, expected) in test_cases { - unsafe { std::env::set_var("TEST_BOOL", value) }; - let result = get_bool("TEST_BOOL", false); - assert_eq!(result, expected, "Failed for value: {value}"); - unsafe { std::env::remove_var("TEST_BOOL") }; - } - - // Test default - let result = get_bool("MISSING_BOOL", true); - assert!(result); - } -} diff --git a/src/sys/env/mod.rs b/src/sys/env/mod.rs deleted file mode 100644 index 3ada4c5..0000000 --- a/src/sys/env/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod error; -pub mod loader; -pub use error::EnvironmentError; -pub use loader::{get_or_default, get_parsed_or_default, get_required}; diff --git a/src/sys/health/components.rs b/src/sys/health/components.rs deleted file mode 100644 index 7218503..0000000 --- a/src/sys/health/components.rs +++ /dev/null @@ -1,21 +0,0 @@ -use crate::{ - dbs::{Database, DbConnection}, - sys::health::traits::HealthCheck, -}; - -/// Creates and returns a vector of all system health checkers. -/// -/// This factory function initializes and collects all components that implement the -/// `HealthCheck` trait, such as database connections or external service clients. -/// Each checker is boxed and added to the vector, which can then be used by the -/// health aggregation service. -#[must_use = "health checkers should be registered or used"] -pub fn create_health_checkers(db_connection: DbConnection) -> Vec> { - vec![Box::new(Database { - db: db_connection, - // No clone needed since db_connection is only used once. - // When adding more components, clone all but the last: - // Box::new(Database { db: db_connection.clone() }), - // Box::new(Cache { db: db_connection }), - })] -} diff --git a/src/sys/health/mod.rs b/src/sys/health/mod.rs deleted file mode 100644 index db7a8df..0000000 --- a/src/sys/health/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -mod components; -mod models; -mod traits; -pub use components::create_health_checkers; -pub use models::{ComponentHealth, HealthStatus, SystemHealthResponse}; -pub use traits::HealthCheck; diff --git a/src/sys/health/models.rs b/src/sys/health/models.rs deleted file mode 100644 index eb2d7ca..0000000 --- a/src/sys/health/models.rs +++ /dev/null @@ -1,24 +0,0 @@ -use serde::Serialize; -#[derive(Serialize, Clone, Debug)] -#[serde(rename_all = "lowercase")] -pub enum HealthStatus { - Healthy, - Degraded, - Unhealthy, -} - -// Single ComponentHealth struct (removing duplicates) -#[derive(Serialize, Debug)] -pub struct ComponentHealth { - pub name: String, - pub status: HealthStatus, - #[serde(skip_serializing_if = "Option::is_none")] - pub message: Option, -} - -#[derive(Serialize)] -pub struct SystemHealthResponse { - pub status: HealthStatus, - pub components: Vec, // Changed from HashMap to Vec - pub timestamp: i64, -} diff --git a/src/sys/health/traits.rs b/src/sys/health/traits.rs deleted file mode 100644 index 5409a04..0000000 --- a/src/sys/health/traits.rs +++ /dev/null @@ -1,7 +0,0 @@ -use super::models::ComponentHealth; - -#[async_trait::async_trait] -pub trait HealthCheck: Send + Sync { - /// Performs the health check and returns component health status. - async fn check(&self) -> ComponentHealth; -} diff --git a/src/sys/init.rs b/src/sys/init.rs index facdec0..ffa09b9 100644 --- a/src/sys/init.rs +++ b/src/sys/init.rs @@ -1,14 +1,12 @@ +use crate::dbs::Database; use crate::{ - AppError, dbs::{DbConfig, DbConnection, connect}, - init_tracing, - sys::{ - config::{server::ServerConfig, state::AppState}, - env, - health::create_health_checkers, - }, + sys::config::{server::ServerConfig, state::AppState}, }; use axum::Router; +use err::AppError; +use hlt::HealthRegistry; +use log::init_tracing; use std::sync::Arc; use tokio::time::{Duration, timeout}; use tower_http::trace::TraceLayer; @@ -125,13 +123,16 @@ pub async fn initialize() -> Result< // Load database connection let connection = load_database().await?; - // Create health checkers - let health_checkers = Arc::new(create_health_checkers(connection.clone())); + // Create health registry + let mut health_registry = HealthRegistry::new(); + health_registry.register(Box::new(Database { + db: connection.clone(), + })); - // Create application state + // Update AppState let state = Arc::new(AppState { db_connection: connection, - health_checkers, + health_registry: Arc::new(health_registry), }); // Load router with state diff --git a/src/sys/log/mod.rs b/src/sys/log/mod.rs deleted file mode 100644 index d17602d..0000000 --- a/src/sys/log/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -mod config; -mod init; -mod models; -pub use init::init_tracing; diff --git a/src/sys/mod.rs b/src/sys/mod.rs index f8ff019..6379ef6 100644 --- a/src/sys/mod.rs +++ b/src/sys/mod.rs @@ -1,9 +1,4 @@ pub mod config; -pub mod env; -pub mod health; pub mod init; -pub mod log; -pub use env::EnvironmentError; pub use init::initialize; -pub use log::init_tracing;