From e94f69fb4dcbd51c6554973956ce43dbc27cc7d2 Mon Sep 17 00:00:00 2001 From: csd113 Date: Tue, 17 Mar 2026 11:15:33 -0700 Subject: [PATCH 1/3] API implemented --- Cargo.lock | 1011 +++++++++++- Cargo.toml | 30 + dev-check-strict.sh | 172 +- rustwave-api/RustWave-tree-annotated.txt | 54 + rustwave-api/rustwave_api_build_plan.md | 1318 +++++++++++++++ .../rustwave_api_staged_build_prompt.md | 1462 +++++++++++++++++ src/api/broadcast.rs | 213 +++ src/api/chan.rs | 107 ++ src/api/errors.rs | 76 + src/api/mod.rs | 66 + src/api/models.rs | 96 ++ src/api/state.rs | 51 + src/api/wave.rs | 122 ++ src/gui.rs | 23 + src/logging.rs | 53 + src/main.rs | 82 +- src/tests.rs | 24 + src/wav.rs | 65 + 18 files changed, 4901 insertions(+), 124 deletions(-) create mode 100644 rustwave-api/RustWave-tree-annotated.txt create mode 100644 rustwave-api/rustwave_api_build_plan.md create mode 100644 rustwave-api/rustwave_api_staged_build_prompt.md create mode 100644 src/api/broadcast.rs create mode 100644 src/api/chan.rs create mode 100644 src/api/errors.rs create mode 100644 src/api/mod.rs create mode 100644 src/api/models.rs create mode 100644 src/api/state.rs create mode 100644 src/api/wave.rs create mode 100644 src/logging.rs create mode 100644 src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 8568a0a..62917f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -129,6 +129,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "android-activity" version = "0.6.0" @@ -473,6 +482,68 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower 0.5.3", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bit-set" version = "0.8.0" @@ -787,7 +858,7 @@ dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -820,6 +891,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -848,6 +928,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -1044,6 +1133,15 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -1189,12 +1287,27 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1202,7 +1315,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1216,6 +1329,12 @@ dependencies = [ "syn", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1231,6 +1350,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -1479,6 +1607,25 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -1535,6 +1682,131 @@ version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -1678,6 +1950,22 @@ dependencies = [ "serde_core", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1798,6 +2086,12 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1880,6 +2174,21 @@ dependencies = [ "libc", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "memchr" version = "2.8.0" @@ -1913,12 +2222,28 @@ dependencies = [ "bitflags 2.11.0", "block", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "log", "objc", "paste", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1929,6 +2254,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -1939,6 +2275,23 @@ dependencies = [ "pxfm", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "naga" version = "24.0.0" @@ -1961,6 +2314,23 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2019,6 +2389,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + [[package]] name = "num-traits" version = "0.2.19" @@ -2342,6 +2727,50 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "orbclient" version = "0.3.50" @@ -2447,6 +2876,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "piper" version = "0.2.5" @@ -2507,7 +2942,13 @@ dependencies = [ ] [[package]] -name = "ppv-lite86" +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" @@ -2664,12 +3105,85 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "renderdoc-sys" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower 0.5.3", + "tower-http 0.6.8", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -2717,6 +3231,39 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2727,11 +3274,30 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" name = "rustwave" version = "0.1.0" dependencies = [ + "anyhow", + "axum", + "bytes", "clap", "eframe", "hound", + "reqwest", + "serde", + "serde_json", + "tokio", + "tower 0.4.13", + "tower-http 0.5.2", + "tracing", + "tracing-appender", + "tracing-subscriber", + "uuid", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -2741,6 +3307,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -2766,6 +3341,29 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.27" @@ -2815,6 +3413,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -2826,6 +3435,18 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2837,6 +3458,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -2968,6 +3598,22 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "spirv" version = "0.3.0+sdk-1.3.268.0" @@ -3023,6 +3669,12 @@ dependencies = [ "syn", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -3034,6 +3686,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -3045,6 +3706,27 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -3107,6 +3789,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "tiff" version = "0.11.3" @@ -3121,6 +3812,37 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -3156,6 +3878,67 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml_datetime" version = "1.0.0+spec-1.1.0" @@ -3186,6 +3969,79 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -3198,6 +4054,18 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +dependencies = [ + "crossbeam-channel", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -3216,8 +4084,57 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", ] +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -3250,6 +4167,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -3274,6 +4197,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -3298,6 +4227,30 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -3314,6 +4267,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -3760,8 +4722,8 @@ checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" dependencies = [ "windows-implement", "windows-interface", - "windows-result", - "windows-strings", + "windows-result 0.2.0", + "windows-strings 0.1.0", "windows-targets 0.52.6", ] @@ -3793,6 +4755,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.2.0" @@ -3802,16 +4775,34 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-strings" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" dependencies = [ - "windows-result", + "windows-result 0.2.0", "windows-targets 0.52.6", ] +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -4434,6 +5425,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/Cargo.toml b/Cargo.toml index a073447..0d2c088 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,36 @@ name = "rustwave-cli" path = "src/main.rs" [dependencies] +# — existing — clap = { version = "4", features = ["derive"] } hound = "3" eframe = "0.31" + +# — NEW: async runtime (required for axum) — +tokio = { version = "1", features = ["full"] } + +# — NEW: HTTP server — +axum = { version = "0.7", features = ["multipart"] } +tower = "0.4" +tower-http = { version = "0.5", features = ["cors", "limit"] } + +# — NEW: HTTP client (forward WAV to Broadcaster) — +reqwest = { version = "0.12", features = ["multipart", "json"] } + +# — NEW: serialisation — +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# — NEW: UUID generation for tx_id / queued_id — +uuid = { version = "1", features = ["v4", "serde"] } + +# — NEW: logging / tracing — +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +tracing-appender = "0.2" + +# — NEW: in-memory byte buffers (WAV bytes without temp files) — +bytes = "1" + +# — NEW: error propagation in run_server — +anyhow = "1" diff --git a/dev-check-strict.sh b/dev-check-strict.sh index 4856127..ba5cb19 100755 --- a/dev-check-strict.sh +++ b/dev-check-strict.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# dev-check.sh — full Rust quality gate +# dev-check.sh — full Rust quality gate (macOS) # Runs: fmt · fix · clippy (pedantic+nursery) · tests · audit · deny · dupes # Produces per-file clustered clippy reports in clippy_reports/ @@ -8,10 +8,21 @@ set -Eeuo pipefail # ─── Colours ────────────────────────────────────────────────────────────────── if [[ -t 1 ]]; then - RED='\033[0;31m'; YELLOW='\033[0;33m'; GREEN='\033[0;32m' - CYAN='\033[0;36m'; BOLD='\033[1m'; DIM='\033[2m'; RESET='\033[0m' + RED='\033[0;31m' + YELLOW='\033[0;33m' + GREEN='\033[0;32m' + CYAN='\033[0;36m' + BOLD='\033[1m' + DIM='\033[2m' + RESET='\033[0m' else - RED=''; YELLOW=''; GREEN=''; CYAN=''; BOLD=''; DIM=''; RESET='' + RED='' + YELLOW='' + GREEN='' + CYAN='' + BOLD='' + DIM='' + RESET='' fi # ─── Globals ────────────────────────────────────────────────────────────────── @@ -29,7 +40,9 @@ SUMMARY_FILE="$REPORT_DIR/summary.txt" # ─── Helpers ────────────────────────────────────────────────────────────────── -command_exists() { command -v "$1" >/dev/null 2>&1; } +has_cmd() { + type -P "$1" >/dev/null 2>&1 +} step() { echo "" @@ -38,37 +51,28 @@ step() { pass() { echo -e " ${GREEN}✓${RESET} $1" - (( PASS_COUNT++ )) || true + PASS_COUNT=$(( PASS_COUNT + 1 )) } fail() { echo -e " ${RED}✗${RESET} $1" - (( FAIL_COUNT++ )) || true + FAIL_COUNT=$(( FAIL_COUNT + 1 )) FAILED_STEPS+=("$1") } skip() { echo -e " ${DIM}–${RESET} $1 ${DIM}(skipped — tool not installed)${RESET}" - (( SKIP_COUNT++ )) || true + SKIP_COUNT=$(( SKIP_COUNT + 1 )) } -warn() { echo -e " ${YELLOW}⚠${RESET} $1"; } - -require_tool() { - local tool="$1" install_hint="$2" - if ! command_exists "$tool"; then - echo -e "${RED}Error:${RESET} required tool '${BOLD}$tool${RESET}' is not installed." - echo -e " Install with: ${DIM}$install_hint${RESET}" - exit 1 - fi +warn() { + echo -e " ${YELLOW}⚠${RESET} $1" } -optional_tool() { - local tool="$1" install_hint="$2" - if ! command_exists "$tool"; then - warn "'$tool' not installed — step will be skipped." - warn "Install with: $install_hint" - fi +die() { + echo -e "${RED}Error:${RESET} $1" + echo -e " Install with: ${DIM}$2${RESET}" + exit 1 } elapsed() { @@ -76,6 +80,23 @@ elapsed() { printf '%dm%02ds' $(( secs / 60 )) $(( secs % 60 )) } +# ─── Strip cargo noise from clippy output ───────────────────────────────────── + +filter_clippy() { + grep -Ev \ + '^[[:space:]]*(Compiling|Checking|Downloading|Updating|Fresh|Finished|Blocking|Locking|Dirty|Scraping|Running|Doctest)[[:space:]]' \ + | grep -Ev \ + '^[[:space:]]*= note: `#\[' \ + | grep -Ev \ + '^warning: [0-9]+ warning(s)? emitted' \ + | grep -Ev \ + '^error: aborting due to' \ + | grep -Ev \ + '^[[:space:]]*= note: for more information' \ + | sed '/^[[:space:]]*$/d' \ + || true +} + # ─── Header ─────────────────────────────────────────────────────────────────── echo -e "${BOLD}" @@ -84,30 +105,44 @@ echo "║ Rust Full Quality Gate Check ║" echo "╚══════════════════════════════════════════════════╝" echo -e "${RESET}" -# ─── CPU cores ──────────────────────────────────────────────────────────────── +# ─── CPU cores (macOS-aware) ────────────────────────────────────────────────── -if command_exists sysctl; then - export CARGO_BUILD_JOBS; CARGO_BUILD_JOBS=$(sysctl -n hw.ncpu 2>/dev/null || echo 4) -elif command_exists nproc; then - export CARGO_BUILD_JOBS; CARGO_BUILD_JOBS=$(nproc) -else - export CARGO_BUILD_JOBS=4 -fi +export CARGO_BUILD_JOBS +CARGO_BUILD_JOBS=$(sysctl -n hw.logicalcpu 2>/dev/null || echo 4) echo -e " ${DIM}Using ${BOLD}${CARGO_BUILD_JOBS}${RESET}${DIM} CPU cores${RESET}" # ─── Required tools ─────────────────────────────────────────────────────────── step "Verifying required tools" -require_tool "cargo" "https://rustup.rs" -require_tool "rustfmt" "rustup component add rustfmt" -require_tool "clippy-driver" "rustup component add clippy" +if ! has_cmd cargo; then + die "required tool 'cargo' is not installed." "https://rustup.rs" +fi + +if ! has_cmd rustfmt; then + die "required tool 'rustfmt' is not installed." "rustup component add rustfmt" +fi + +# clippy-driver is not always on PATH on macOS — check via cargo subcommand instead +if ! cargo clippy --version >/dev/null 2>&1; then + die "required tool 'clippy' is not installed." "rustup component add clippy" +fi + pass "cargo · rustfmt · clippy all present" -optional_tool "cargo-audit" "cargo install cargo-audit" -optional_tool "cargo-deny" "cargo install cargo-deny" -optional_tool "cargo-udeps" "cargo install cargo-udeps" -optional_tool "cargo-msrv" "cargo install cargo-msrv" +# Optional tools — warn but do not exit +if ! has_cmd cargo-audit; then + warn "'cargo-audit' not installed — step will be skipped. cargo install cargo-audit" +fi +if ! has_cmd cargo-deny; then + warn "'cargo-deny' not installed — step will be skipped. cargo install cargo-deny" +fi +if ! has_cmd cargo-udeps; then + warn "'cargo-udeps' not installed — step will be skipped. cargo install cargo-udeps" +fi +if ! has_cmd cargo-msrv; then + warn "'cargo-msrv' not installed — step will be skipped. cargo install cargo-msrv" +fi # ─── Prepare report directory ───────────────────────────────────────────────── @@ -118,7 +153,11 @@ mkdir -p "$CLUSTER_DIR" if [[ "${1:-}" == "--update" ]]; then step "Updating dependency index" - if cargo update 2>&1; then pass "cargo update"; else fail "cargo update"; fi + if cargo update 2>&1; then + pass "cargo update" + else + fail "cargo update" + fi fi # ─── 1. Format ──────────────────────────────────────────────────────────────── @@ -131,7 +170,6 @@ else fail "cargo fmt --all" fi -# Verify nothing was left dirty (useful in CI) if git diff --quiet 2>/dev/null; then pass "No unstaged format changes" else @@ -153,42 +191,25 @@ fi step "3 · Lint (cargo clippy — pedantic + nursery)" CLIPPY_FLAGS=( - # Hard errors "-D" "warnings" - - # Pedantic: correctness, performance, style improvements "-W" "clippy::pedantic" - - # Nursery: newer lints, some may be noisy — catches subtle bugs early "-W" "clippy::nursery" - - # Catch common correctness bugs missed by the default set "-W" "clippy::correctness" "-W" "clippy::suspicious" "-W" "clippy::complexity" "-W" "clippy::perf" - - # Panic/unwrap hygiene — forces explicit error handling "-W" "clippy::unwrap_used" "-W" "clippy::expect_used" "-W" "clippy::panic" "-W" "clippy::todo" "-W" "clippy::unimplemented" "-W" "clippy::unreachable" - - # Index panic risk "-W" "clippy::indexing_slicing" - - # Integer overflow in casts "-W" "clippy::cast_possible_truncation" "-W" "clippy::cast_possible_wrap" "-W" "clippy::cast_sign_loss" "-W" "clippy::cast_precision_loss" - - # Arithmetic that can panic "-W" "clippy::arithmetic_side_effects" - - # Formatting / style discipline "-W" "clippy::format_collect" "-W" "clippy::uninlined_format_args" "-W" "clippy::redundant_closure_for_method_calls" @@ -209,6 +230,7 @@ CLIPPY_CMD=( cargo clippy --all-targets --all-features + --message-format=short -- "${CLIPPY_FLAGS[@]}" ) @@ -217,14 +239,17 @@ echo -e " ${DIM}Running: ${CLIPPY_CMD[*]}${RESET}" echo "" CLIPPY_EXIT=0 -"${CLIPPY_CMD[@]}" 2>&1 | tee "$RAW_FILE" || CLIPPY_EXIT=$? +"${CLIPPY_CMD[@]}" 2>&1 \ + | filter_clippy \ + | tee "$RAW_FILE" \ + || CLIPPY_EXIT=${PIPESTATUS[0]} -# ── Cluster clippy output by source file ───────────────────────────────────── +# ── Cluster clippy output by source file ────────────────────────────────────── echo "" echo -e " ${DIM}Clustering clippy output by file...${RESET}" -OUTFILE="" +CURRENT_OUTFILE="" while IFS= read -r line; do if [[ $line =~ ([a-zA-Z0-9_/.-]+\.rs):[0-9]+:[0-9]+ ]]; then FILE="${BASH_REMATCH[1]}" @@ -234,16 +259,16 @@ while IFS= read -r line; do else CLUSTER=$(echo "$DIR" | tr '/' '_') fi - OUTFILE="$CLUSTER_DIR/${CLUSTER}.txt" + CURRENT_OUTFILE="$CLUSTER_DIR/${CLUSTER}.txt" { echo "" echo "----------------------------------------" echo "FILE: $FILE" echo "----------------------------------------" - } >> "$OUTFILE" + } >> "$CURRENT_OUTFILE" fi - if [[ -n "$OUTFILE" ]]; then - echo "$line" >> "$OUTFILE" + if [[ -n "$CURRENT_OUTFILE" ]]; then + echo "$line" >> "$CURRENT_OUTFILE" fi done < "$RAW_FILE" @@ -273,7 +298,6 @@ TEST_EXIT=0 cargo test --all --all-features 2>&1 || TEST_EXIT=$? if [[ $TEST_EXIT -eq 0 ]]; then - PASSED=$(grep -oP '\d+(?= passed)' <<< "$(cargo test --all --all-features 2>&1)" | tail -1 || echo "?") pass "All tests passed" else fail "Test suite failed (exit $TEST_EXIT)" @@ -283,7 +307,7 @@ fi step "5 · Security audit (cargo audit)" -if command_exists cargo-audit; then +if has_cmd cargo-audit; then AUDIT_EXIT=0 cargo audit 2>&1 || AUDIT_EXIT=$? if [[ $AUDIT_EXIT -eq 0 ]]; then @@ -299,7 +323,7 @@ fi step "6 · Dependency policy (cargo deny)" -if command_exists cargo-deny; then +if has_cmd cargo-deny; then DENY_EXIT=0 cargo deny check 2>&1 || DENY_EXIT=$? if [[ $DENY_EXIT -eq 0 ]]; then @@ -315,7 +339,7 @@ fi step "7 · Unused dependencies (cargo udeps)" -if command_exists cargo-udeps; then +if has_cmd cargo-udeps; then UDEPS_EXIT=0 cargo +nightly udeps --all-targets 2>&1 || UDEPS_EXIT=$? if [[ $UDEPS_EXIT -eq 0 ]]; then @@ -331,7 +355,7 @@ fi step "8 · Minimum supported Rust version (cargo msrv)" -if command_exists cargo-msrv; then +if has_cmd cargo-msrv; then MSRV_EXIT=0 cargo msrv verify 2>&1 || MSRV_EXIT=$? if [[ $MSRV_EXIT -eq 0 ]]; then @@ -350,8 +374,8 @@ step "9 · Duplicate dependencies (cargo tree -d)" DUPES=$(cargo tree -d 2>&1 || true) if echo "$DUPES" | grep -q '\['; then warn "Duplicate crate versions detected:" - echo "$DUPES" | grep '^\[' | sort -u | while read -r line; do - echo -e " ${YELLOW}$line${RESET}" + echo "$DUPES" | grep '^\[' | sort -u | while IFS= read -r line; do + echo -e " ${YELLOW}${line}${RESET}" done else pass "No duplicate crate versions" @@ -382,7 +406,9 @@ TOTAL_SECS=$(( SECONDS - SCRIPT_START )) if [[ ${#FAILED_STEPS[@]} -gt 0 ]]; then echo "" echo "Failed steps:" - for s in "${FAILED_STEPS[@]}"; do echo " - $s"; done + for s in "${FAILED_STEPS[@]}"; do + echo " - $s" + done fi } | tee "$SUMMARY_FILE" @@ -407,4 +433,4 @@ else echo "" echo -e " ${DIM}Reports saved to: $REPORT_DIR/${RESET}" exit 1 -fi +fi \ No newline at end of file diff --git a/rustwave-api/RustWave-tree-annotated.txt b/rustwave-api/RustWave-tree-annotated.txt new file mode 100644 index 0000000..0d2a8b8 --- /dev/null +++ b/rustwave-api/RustWave-tree-annotated.txt @@ -0,0 +1,54 @@ +├── Cargo.lock +├── Cargo.toml +├── changelog.txt +├── deny.toml +├── dev-check-strict.sh +├── docs +│ ├── hamnet-relay-build-roadmap.md +│ └── rustwave_impl_plan.md +├── LICENSE +├── README.md +├── rustwave_api_implementation_plan.md +├── src +│ ├── api +│ │ ├── mod.rs # Router builder: full_router (wave + broadcast + chan routes), +│ │ │ # gui_router (broadcast + chan routes), run_server; 10 MB body limit +│ │ ├── models.rs # All JSON request/response structs; ChanCommand tagged enum +│ │ │ # (full_export, board_export, thread_export, archive_export, +│ │ │ # force_refresh, reply_push) matching ChanNet /chan/command API +│ │ ├── errors.rs # ApiError enum with IntoResponse impl +│ │ ├── state.rs # AppState (broadcaster_url, channet_url, wave_routes_enabled, +│ │ │ # incoming_queue); IncomingQueue; QueuedFile +│ │ ├── wave.rs # Handlers: GET /wave/status, POST /wave/encode, POST /wave/decode +│ │ │ # (serve mode only) +│ │ ├── broadcast.rs # Handlers: GET /broadcast/status, POST /broadcast/transmit, +│ │ │ # POST /broadcast/receive, GET /broadcast/incoming; +│ │ │ # pub forward_to_broadcaster() reused by chan.rs; +│ │ │ # ChanNet calls /broadcast/transmit to push ZIP snapshots and +│ │ │ # GET /broadcast/incoming to pull decoded inbound payloads +│ │ ├── chan.rs # ChanNet client + /chan/request proxy handler: +│ │ │ # check_channet_reachable() → GET /chan/status probe; +│ │ │ # send_chan_command() → POST /chan/command, returns raw ZIP bytes; +│ │ │ # POST /chan/request handler: receives ChanCommand JSON → +│ │ │ # forwards to ChanNet /chan/command → AFSK-encodes ZIP into WAV +│ │ │ # → calls forward_to_broadcaster() for over-the-air transmission +│ │ └── tests.rs # Unit test: queue enqueue/dequeue round-trip +│ ├── config.rs # Shared constants: sample rate (44 100 Hz), baud rate (1 200), +│ │ # MARK/SPACE frequencies, amplitude, preamble length & sync word +│ ├── decoder.rs # PCM → bytes: Goertzel-filter bit detection, clock-phase search, +│ │ # sync-word alignment, and frame extraction (with progress callback) +│ ├── encoder.rs # bytes → PCM: AFSK sine-wave synthesis at MARK/SPACE freqs, +│ │ # 50 ms silence padding, optional progress callback +│ ├── framer.rs # Wire-frame builder/parser: preamble + sync word + u16 filename +│ │ # length + filename + u32 payload length + payload + CRC-16/CCITT +│ ├── gui.rs # egui drag-and-drop front-end: auto-detects WAV vs. other file, +│ │ # runs encode/decode on a background thread, shows progress bar; +│ │ # spawns broadcast + chan API server thread before launching eframe +│ ├── logging.rs # Logging init: stderr INFO+ (human-readable) + rolling JSON file +│ │ # DEBUG+; respects RUSTWAVE_LOG env var +│ ├── main.rs # CLI entry point (clap): `gui`, `serve`, `encode -i … -o …`, +│ │ # `decode -i … [-o …]` +│ └── wav.rs # WAV I/O via hound: write normalised f64→i16 PCM; read i16 PCM→f64 +│ # (stereo accepted; only left channel used); +│ # write_to_bytes / read_from_bytes for in-memory API use +└── todo.txt \ No newline at end of file diff --git a/rustwave-api/rustwave_api_build_plan.md b/rustwave-api/rustwave_api_build_plan.md new file mode 100644 index 0000000..d4fc1b8 --- /dev/null +++ b/rustwave-api/rustwave_api_build_plan.md @@ -0,0 +1,1318 @@ +# RustWave API — Build Plan +*Derived from `rustwave_api_implementation_plan.md` · March 2026* + +--- + +## Files to Edit or Create — Summary + +| File | Action | Why | +|---|---|---| +| `Cargo.toml` | **EDIT** | Add tokio, axum, tower, tower-http, reqwest, serde, uuid, tracing, bytes | +| `src/main.rs` | **EDIT** | Add `serve` subcommand, logging init, `mod api`, `mod logging` | +| `src/gui.rs` | **EDIT** | Spawn broadcast API thread before launching eframe | +| `src/wav.rs` | **EDIT** | Add `write_to_bytes` and `read_from_bytes` + new unit test | +| `src/logging.rs` | **CREATE** | Logging initialisation (stderr + rolling JSON file) | +| `src/api/mod.rs` | **CREATE** | Router builder: `full_router`, `gui_router`, `run_server` | +| `src/api/models.rs` | **CREATE** | All JSON request/response structs | +| `src/api/errors.rs` | **CREATE** | `ApiError` enum with `IntoResponse` impl | +| `src/api/state.rs` | **CREATE** | `AppState`, `IncomingQueue`, `QueuedFile` | +| `src/api/wave.rs` | **CREATE** | Handlers: `GET /wave/status`, `POST /wave/encode`, `POST /wave/decode` | +| `src/api/broadcast.rs` | **CREATE** | Handlers: `GET /broadcast/status`, `POST /broadcast/transmit`, `POST /broadcast/receive`, `GET /broadcast/incoming`; `pub forward_to_broadcaster()` shared with `chan.rs` | +| `src/api/chan.rs` | **CREATE** | ChanNet HTTP client (`check_channet_reachable`, `send_chan_command`) and `POST /chan/request` proxy handler — fetches ZIP from ChanNet, AFSK-encodes it, forwards WAV to Broadcaster for over-the-air transmission | +| `src/api/tests.rs` | **CREATE** | Unit test: queue enqueue/dequeue round-trip | + +**Unchanged:** `src/config.rs`, `src/encoder.rs`, `src/decoder.rs`, `src/framer.rs`, `deny.toml`, `Cargo.lock` (auto-updated). + +--- + +## Step-by-Step Build Plan + +Each step ends with `cargo check`. Do **not** advance until it is green. Commit after each step. + +--- + +### Step 1 — `Cargo.toml` + +Replace the `[dependencies]` block: + +```toml +[package] +name = "rustwave" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "RustWave audio codec — encode bytes to WAV, decode WAV to bytes" + +[[bin]] +name = "rustwave-cli" +path = "src/main.rs" + +[dependencies] +# — existing — +clap = { version = "4", features = ["derive"] } +hound = "3" +eframe = "0.31" + +# — NEW: async runtime (required for axum) — +tokio = { version = "1", features = ["full"] } + +# — NEW: HTTP server — +axum = { version = "0.7", features = ["multipart"] } +tower = "0.4" +tower-http = { version = "0.5", features = ["cors", "limit"] } + +# — NEW: HTTP client (forward WAV to Broadcaster) — +reqwest = { version = "0.12", features = ["multipart"] } + +# — NEW: serialisation — +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# — NEW: UUID generation for tx_id / queued_id — +uuid = { version = "1", features = ["v4"] } + +# — NEW: logging / tracing — +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +tracing-appender = "0.2" + +# — NEW: in-memory byte buffers (WAV bytes without temp files) — +bytes = "1" +``` + +```bash +cargo check # must be green before continuing +``` + +**Commit:** `chore: add API dependencies to Cargo.toml` + +--- + +### Step 2 — `src/logging.rs` *(CREATE)* + +```rust +//! Logging initialisation for RustWave. +//! +//! Call `logging::init()` once at the start of `main()`. +//! +//! Log output: +//! - stderr: INFO and above, human-readable +//! - rustwave.log (file): DEBUG and above, JSON format, rolling daily +//! +//! The log file is written next to the binary. +//! Set RUSTWAVE_LOG=debug to see debug output on stderr too. + +use std::path::PathBuf; +use tracing_appender::rolling; +use tracing_subscriber::{ + fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, +}; + +/// Initialise logging. Must be called once before any tracing macros are used. +/// +/// Returns the `_guard` from `tracing_appender::non_blocking`. The caller MUST +/// hold this value for the lifetime of the process; dropping it flushes and +/// closes the log file. +pub fn init() -> tracing_appender::non_blocking::WorkerGuard { + let log_dir: PathBuf = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(PathBuf::from)) + .unwrap_or_else(|| PathBuf::from(".")); + + // Rolling daily log file: rustwave.YYYY-MM-DD + let file_appender = rolling::daily(&log_dir, "rustwave.log"); + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + + // stderr layer — human readable, INFO+ by default, respects RUSTWAVE_LOG + let stderr_filter = EnvFilter::try_from_env("RUSTWAVE_LOG") + .unwrap_or_else(|_| EnvFilter::new("info")); + + let stderr_layer = fmt::layer() + .with_target(false) + .with_filter(stderr_filter); + + // file layer — JSON, DEBUG+ + let file_layer = fmt::layer() + .json() + .with_writer(non_blocking) + .with_filter(EnvFilter::new("debug")); + + tracing_subscriber::registry() + .with(stderr_layer) + .with(file_layer) + .init(); + + guard +} +``` + +```bash +cargo check +``` + +**Commit:** `feat: add logging module` + +--- + +### Step 3 — `src/api/models.rs` *(CREATE)* + +```bash +mkdir src/api +``` + +```rust +//! JSON request and response types for the RustWave API. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// ── /wave/* responses ────────────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct WaveStatusResponse { + pub service: &'static str, + pub codec: &'static str, + pub version: &'static str, +} + +// ── /broadcast/* responses ───────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct BroadcastStatusResponse { + pub service: &'static str, + pub broadcaster_connected: bool, + pub channet_connected: bool, + pub broadcaster_url: String, + pub queue_depth: usize, +} + +#[derive(Serialize)] +pub struct TransmitResponse { + pub status: &'static str, + pub tx_id: Uuid, + pub wav_bytes: usize, +} + +#[derive(Serialize)] +pub struct ReceiveResponse { + pub status: &'static str, + pub queued_id: Uuid, + pub decoded_bytes: usize, +} + +/// Returned by GET /broadcast/incoming when the queue is empty. +#[derive(Serialize, Deserialize)] +pub struct QueueEmptyResponse { + pub status: &'static str, +} + +// ── ChanNet /chan/command request types ──────────────────────────────────── +// +// Mirrors the six commands defined in the ChanNet API reference exactly. +// The `type` field is serialised as the serde tag so the JSON sent to +// /chan/command matches the format ChanNet expects. + +#[derive(Serialize, Deserialize, Debug)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ChanCommand { + /// All boards + all active (non-archived) posts. Optional delta via `since`. + FullExport { since: Option }, + /// All active posts on a single board. Optional delta via `since`. + BoardExport { board: String, since: Option }, + /// All posts in a single thread. Optional delta via `since`. + ThreadExport { thread_id: u64, since: Option }, + /// All archived threads + posts for a single board. Always a full export. + ArchiveExport { board: String }, + /// Entire database — all boards, threads, archives, posts. Use for initial + /// sync or recovery only; RustChan logs a warning when this is received. + ForceRefresh, + /// Post a new reply to an existing thread (the only write command). + ReplyPush { + board: String, + thread_id: u64, + author: String, + content: String, + timestamp: u64, + }, +} + +/// Returned by POST /chan/request on success. +#[derive(Serialize)] +pub struct ChanRequestResponse { + pub status: &'static str, // "transmitted" + pub tx_id: uuid::Uuid, + pub zip_bytes: usize, +} + +// ── Error envelope (Section 2.4) ─────────────────────────────────────────── + +#[derive(Serialize)] +pub struct ErrorDetail { + pub code: String, + pub message: String, + pub status: u16, +} + +#[derive(Serialize)] +pub struct ErrorEnvelope { + pub error: ErrorDetail, +} +``` + +```bash +cargo check +``` + +**Commit:** `feat: add api/models.rs` + +--- + +### Step 4 — `src/api/errors.rs` *(CREATE)* + +```rust +//! API error type for RustWave. +//! +//! Every handler returns `Result<_, ApiError>`. axum automatically calls +//! `IntoResponse` on the error path. + +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use crate::api::models::{ErrorDetail, ErrorEnvelope}; + +#[derive(Debug)] +pub enum ApiError { + BadRequest(String), + PayloadTooLarge, + EncodeFailed(String), + DecodeFailed(String), + BroadcasterUnavailable(String), + Internal(String), +} + +impl ApiError { + fn code(&self) -> &'static str { + match self { + Self::BadRequest(_) => "BAD_REQUEST", + Self::PayloadTooLarge => "PAYLOAD_TOO_LARGE", + Self::EncodeFailed(_) => "ENCODE_FAILED", + Self::DecodeFailed(_) => "DECODE_FAILED", + Self::BroadcasterUnavailable(_) => "BROADCASTER_UNAVAILABLE", + Self::Internal(_) => "INTERNAL_ERROR", + } + } + + fn status_code(&self) -> StatusCode { + match self { + Self::BadRequest(_) => StatusCode::BAD_REQUEST, + Self::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE, + Self::EncodeFailed(_) => StatusCode::UNPROCESSABLE_ENTITY, + Self::DecodeFailed(_) => StatusCode::UNPROCESSABLE_ENTITY, + Self::BroadcasterUnavailable(_) => StatusCode::BAD_GATEWAY, + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let status = self.status_code(); + let message = match &self { + Self::BadRequest(m) => m.clone(), + Self::PayloadTooLarge => "Request body exceeds the 10 MB limit.".into(), + Self::EncodeFailed(m) => m.clone(), + Self::DecodeFailed(m) => m.clone(), + Self::BroadcasterUnavailable(m) => m.clone(), + Self::Internal(m) => m.clone(), + }; + + tracing::error!( + code = self.code(), + http_status = status.as_u16(), + %message, + "api error" + ); + + let body = ErrorEnvelope { + error: ErrorDetail { + code: self.code().into(), + message, + status: status.as_u16(), + }, + }; + + (status, Json(body)).into_response() + } +} +``` + +```bash +cargo check +``` + +**Commit:** `feat: add api/errors.rs` + +--- + +### Step 5 — `src/api/state.rs` *(CREATE)* + +```rust +//! Shared state for the RustWave API server. + +use std::{collections::VecDeque, sync::Arc}; +use bytes::Bytes; +use tokio::sync::Mutex; +use uuid::Uuid; + +#[derive(Debug)] +pub struct QueuedFile { + pub queued_id: Uuid, + pub bytes: Bytes, +} + +pub type IncomingQueue = Arc>>; + +#[derive(Clone)] +pub struct AppState { + pub broadcaster_url: String, + pub channet_url: String, + pub wave_routes_enabled: bool, + pub incoming_queue: IncomingQueue, +} + +impl AppState { + pub fn new(wave_routes_enabled: bool) -> Self { + let broadcaster_url = std::env::var("RUSTWAVE_BROADCASTER_URL") + .unwrap_or_else(|_| "http://localhost:9090".to_string()); + + let channet_url = std::env::var("RUSTWAVE_CHANNET_URL") + .unwrap_or_else(|_| "http://localhost:7070".to_string()); + + Self { + broadcaster_url, + channet_url, + wave_routes_enabled, + incoming_queue: Arc::new(Mutex::new(VecDeque::new())), + } + } + + pub async fn queue_depth(&self) -> usize { + self.incoming_queue.lock().await.len() + } + + pub async fn enqueue(&self, file: QueuedFile) { + self.incoming_queue.lock().await.push_back(file); + } + + pub async fn dequeue(&self) -> Option { + self.incoming_queue.lock().await.pop_front() + } +} +``` + +```bash +cargo check +``` + +**Commit:** `feat: add api/state.rs` + +--- + +### Step 6 — `src/api/wave.rs` *(CREATE)* + +```rust +//! Handlers for the /wave/* general-purpose codec endpoints. +//! Only registered in `serve` mode — NOT in GUI mode. + +use axum::{ + extract::Multipart, + http::header, + response::{IntoResponse, Response}, + Json, +}; +use tracing::{info, warn}; + +use crate::{ + api::{errors::ApiError, models::WaveStatusResponse}, + decoder, encoder, framer, wav, +}; + +// ── GET /wave/status ─────────────────────────────────────────────────────── + +pub async fn wave_status() -> Json { + info!("GET /wave/status"); + Json(WaveStatusResponse { + service: "rustwave", + codec: "afsk-1200", + version: env!("CARGO_PKG_VERSION"), + }) +} + +// ── POST /wave/encode ────────────────────────────────────────────────────── + +pub async fn wave_encode(mut multipart: Multipart) -> Result { + let (filename, file_bytes) = extract_file_field(&mut multipart).await?; + + info!(filename = %filename, input_bytes = file_bytes.len(), "POST /wave/encode starting"); + + let result = tokio::task::spawn_blocking(move || { + let framed = framer::frame(&file_bytes, &filename); + let samples = encoder::encode(&framed); + let wav_bytes = wav::write_to_bytes(&samples)?; + Ok::<(String, Vec), String>((filename, wav_bytes)) + }) + .await + .map_err(|e| ApiError::Internal(format!("task panic: {e}")))? + .map_err(ApiError::EncodeFailed)?; + + let (original_filename, wav_bytes) = result; + let stem = std::path::Path::new(&original_filename) + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + let out_name = format!("{stem}_encoded.wav"); + + info!(output_filename = %out_name, wav_bytes = wav_bytes.len(), "POST /wave/encode complete"); + + Ok(( + [ + (header::CONTENT_TYPE, "audio/wav"), + (header::CONTENT_DISPOSITION, &format!("attachment; filename=\"{out_name}\"")), + ], + wav_bytes, + ) + .into_response()) +} + +// ── POST /wave/decode ────────────────────────────────────────────────────── + +pub async fn wave_decode(mut multipart: Multipart) -> Result { + let (_field_name, wav_bytes) = extract_file_field(&mut multipart).await?; + + info!(wav_bytes = wav_bytes.len(), "POST /wave/decode starting"); + + let result = tokio::task::spawn_blocking(move || { + let samples = wav::read_from_bytes(&wav_bytes)?; + let decoded = decoder::decode(&samples)?; + Ok::(decoded) + }) + .await + .map_err(|e| ApiError::Internal(format!("task panic: {e}")))? + .map_err(ApiError::DecodeFailed)?; + + info!( + original_filename = %result.filename, + decoded_bytes = result.data.len(), + "POST /wave/decode complete" + ); + + Ok(( + [ + (header::CONTENT_TYPE, "application/octet-stream"), + (header::CONTENT_DISPOSITION, &format!("attachment; filename=\"{}\"", result.filename)), + ], + result.data, + ) + .into_response()) +} + +// ── Shared helper ────────────────────────────────────────────────────────── + +async fn extract_file_field( + multipart: &mut Multipart, +) -> Result<(String, Vec), ApiError> { + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| ApiError::BadRequest(format!("multipart error: {e}")))? + { + let filename = field.file_name().unwrap_or("upload").to_string(); + let data = field + .bytes() + .await + .map_err(|e| ApiError::BadRequest(format!("could not read field bytes: {e}")))?; + + if data.is_empty() { + warn!(filename = %filename, "received empty file field"); + return Err(ApiError::BadRequest("file field is empty".into())); + } + + return Ok((filename, data.to_vec())); + } + + Err(ApiError::BadRequest("no file field found in multipart body".into())) +} +``` + +```bash +cargo check +``` + +**Commit:** `feat: add api/wave.rs handlers` + +--- + +### Step 7 — `src/api/broadcast.rs` *(CREATE)* + +```rust +//! Handlers for the /broadcast/* channel network endpoints. +//! Exposed in both `serve` mode and `gui` mode. + +use axum::{ + extract::{Multipart, State}, + http::{header, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use bytes::Bytes; +use tracing::{debug, info, warn}; +use uuid::Uuid; + +use crate::{ + api::{ + errors::ApiError, + models::{BroadcastStatusResponse, QueueEmptyResponse, ReceiveResponse, TransmitResponse}, + state::{AppState, QueuedFile}, + }, + decoder, encoder, framer, wav, +}; + +// ── GET /broadcast/status ────────────────────────────────────────────────── + +pub async fn broadcast_status(State(state): State) -> Json { + let queue_depth = state.queue_depth().await; + let broadcaster_connected = check_broadcaster_reachable(&state.broadcaster_url).await; + let channet_connected = crate::api::chan::check_channet_reachable(&state.channet_url).await; + + info!( + queue_depth, + broadcaster_connected, + channet_connected, + broadcaster_url = %state.broadcaster_url, + channet_url = %state.channet_url, + "GET /broadcast/status" + ); + + Json(BroadcastStatusResponse { + service: "rustwave", + broadcaster_connected, + channet_connected, + broadcaster_url: state.broadcaster_url.clone(), + queue_depth, + }) +} + +async fn check_broadcaster_reachable(url: &str) -> bool { + match reqwest::Client::new() + .get(url) + .timeout(std::time::Duration::from_secs(2)) + .send() + .await + { + Ok(r) => r.status().is_success(), + Err(_) => false, + } +} + +// ── POST /broadcast/transmit ─────────────────────────────────────────────── + +pub async fn broadcast_transmit( + State(state): State, + mut multipart: Multipart, +) -> Result, ApiError> { + let (filename, file_bytes) = extract_file_field(&mut multipart).await?; + + info!(filename = %filename, input_bytes = file_bytes.len(), "POST /broadcast/transmit received file"); + + let wav_bytes: Vec = tokio::task::spawn_blocking({ + let filename = filename.clone(); + move || { + let framed = framer::frame(&file_bytes, &filename); + let samples = encoder::encode(&framed); + wav::write_to_bytes(&samples) + } + }) + .await + .map_err(|e| ApiError::Internal(format!("task panic: {e}")))? + .map_err(ApiError::EncodeFailed)?; + + let wav_size = wav_bytes.len(); + let tx_id = Uuid::new_v4(); + + info!(%tx_id, wav_bytes = wav_size, broadcaster_url = %state.broadcaster_url, + "POST /broadcast/transmit forwarding to Broadcaster"); + + forward_to_broadcaster(&state.broadcaster_url, &filename, wav_bytes, tx_id).await?; + + Ok(Json(TransmitResponse { status: "ok", tx_id, wav_bytes: wav_size })) +} + +pub async fn forward_to_broadcaster( + broadcaster_url: &str, + original_filename: &str, + wav_bytes: Vec, + tx_id: Uuid, +) -> Result<(), ApiError> { + let stem = std::path::Path::new(original_filename) + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + let wav_filename = format!("{stem}_encoded.wav"); + + let part = reqwest::multipart::Part::bytes(wav_bytes) + .file_name(wav_filename) + .mime_str("audio/wav") + .map_err(|e| ApiError::Internal(format!("mime error: {e}")))?; + let form = reqwest::multipart::Form::new().part("file", part); + + let resp = reqwest::Client::new() + .post(broadcaster_url) + .multipart(form) + .send() + .await + .map_err(|e| ApiError::BroadcasterUnavailable( + format!("could not reach Broadcaster at {broadcaster_url}: {e}") + ))?; + + if !resp.status().is_success() { + return Err(ApiError::BroadcasterUnavailable(format!( + "Broadcaster returned HTTP {} for tx_id {tx_id}", resp.status() + ))); + } + + debug!(%tx_id, "Broadcaster accepted WAV"); + Ok(()) +} + +// ── POST /broadcast/receive ──────────────────────────────────────────────── + +pub async fn broadcast_receive( + State(state): State, + mut multipart: Multipart, +) -> Result, ApiError> { + let (_filename, wav_bytes) = extract_file_field(&mut multipart).await?; + + info!(wav_bytes = wav_bytes.len(), "POST /broadcast/receive decoding WAV"); + + let decoded = tokio::task::spawn_blocking(move || { + let samples = wav::read_from_bytes(&wav_bytes)?; + decoder::decode(&samples) + }) + .await + .map_err(|e| ApiError::Internal(format!("task panic: {e}")))? + .map_err(ApiError::DecodeFailed)?; + + let decoded_size = decoded.data.len(); + let queued_id = Uuid::new_v4(); + + info!( + %queued_id, + original_filename = %decoded.filename, + decoded_bytes = decoded_size, + "POST /broadcast/receive queuing decoded file" + ); + + state.enqueue(QueuedFile { queued_id, bytes: Bytes::from(decoded.data) }).await; + + Ok(Json(ReceiveResponse { status: "ok", queued_id, decoded_bytes: decoded_size })) +} + +// ── GET /broadcast/incoming ──────────────────────────────────────────────── + +pub async fn broadcast_incoming(State(state): State) -> Response { + match state.dequeue().await { + Some(file) => { + info!(queued_id = %file.queued_id, bytes = file.bytes.len(), + "GET /broadcast/incoming dequeuing file"); + ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, "application/octet-stream"), + (header::CONTENT_DISPOSITION, "attachment; filename=\"snapshot.zip\""), + ], + file.bytes, + ) + .into_response() + } + None => { + debug!("GET /broadcast/incoming queue is empty"); + (StatusCode::OK, Json(QueueEmptyResponse { status: "empty" })).into_response() + } + } +} + +// ── Shared helper ────────────────────────────────────────────────────────── + +async fn extract_file_field( + multipart: &mut Multipart, +) -> Result<(String, Vec), ApiError> { + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| ApiError::BadRequest(format!("multipart error: {e}")))? + { + let filename = field.file_name().unwrap_or("upload").to_string(); + let data = field + .bytes() + .await + .map_err(|e| ApiError::BadRequest(format!("could not read field bytes: {e}")))?; + + if data.is_empty() { + warn!(filename = %filename, "received empty file field"); + return Err(ApiError::BadRequest("file field is empty".into())); + } + + return Ok((filename, data.to_vec())); + } + + Err(ApiError::BadRequest("no file field found in multipart body".into())) +} +``` + +```bash +cargo check +``` + +**Commit:** `feat: add api/broadcast.rs handlers` + +--- + +### Step 7.5 — `src/api/chan.rs` *(CREATE)* + +```rust +//! ChanNet HTTP client and /chan/request proxy handler. +//! +//! ChanNet already calls RustWave on: +//! POST /broadcast/transmit — pushes a ZIP snapshot for AFSK encoding & over-air transmission +//! GET /broadcast/incoming — pulls decoded ZIP snapshots that arrived over radio +//! +//! This module adds the outbound direction: +//! POST /chan/request — operator sends a typed ChanCommand; RustWave forwards it to +//! ChanNet's /chan/command, receives the ZIP response, AFSK-encodes +//! it into WAV, and calls forward_to_broadcaster() for transmission. + +use axum::{extract::State, Json}; +use tracing::info; +use uuid::Uuid; + +use crate::api::{ + errors::ApiError, + models::{ChanCommand, ChanRequestResponse}, + state::AppState, +}; +use crate::{encoder, framer, wav}; + +// ── Reachability probe ───────────────────────────────────────────────────── + +/// Hits ChanNet's GET /chan/status. Used by broadcast_status to report +/// whether the paired ChanNet node is reachable. +pub async fn check_channet_reachable(channet_url: &str) -> bool { + match reqwest::Client::new() + .get(format!("{channet_url}/chan/status")) + .timeout(std::time::Duration::from_secs(2)) + .send() + .await + { + Ok(r) => r.status().is_success(), + Err(_) => false, + } +} + +// ── ChanNet command client ───────────────────────────────────────────────── + +/// POST a typed ChanCommand to ChanNet's /chan/command endpoint. +/// Returns the raw ZIP bytes from the response body. +pub async fn send_chan_command( + channet_url: &str, + command: &ChanCommand, +) -> Result { + let resp = reqwest::Client::new() + .post(format!("{channet_url}/chan/command")) + .json(command) + .send() + .await + .map_err(|e| ApiError::BroadcasterUnavailable( + format!("ChanNet unreachable at {channet_url}: {e}") + ))?; + + if !resp.status().is_success() { + return Err(ApiError::BroadcasterUnavailable( + format!("ChanNet /chan/command returned HTTP {}", resp.status()), + )); + } + + resp.bytes() + .await + .map_err(|e| ApiError::Internal(format!("reading ChanNet response body: {e}"))) +} + +// ── POST /chan/request ───────────────────────────────────────────────────── + +pub async fn chan_request( + State(state): State, + Json(command): Json, +) -> Result, ApiError> { + info!(?command, channet_url = %state.channet_url, "POST /chan/request"); + + // 1. Fetch ZIP from ChanNet. + let zip_bytes = send_chan_command(&state.channet_url, &command).await?; + let zip_len = zip_bytes.len(); + + // 2. AFSK-encode the ZIP into WAV bytes (CPU-bound; run on blocking thread). + let wav_bytes: Vec = tokio::task::spawn_blocking(move || { + let framed = framer::frame(&zip_bytes, "channet_payload.zip"); + let samples = encoder::encode(&framed); + wav::write_to_bytes(&samples) + }) + .await + .map_err(|e| ApiError::Internal(format!("task panic: {e}")))? + .map_err(ApiError::EncodeFailed)?; + + // 3. Forward the WAV to the external Broadcaster for over-air transmission. + // Reuses the same helper as /broadcast/transmit. + let tx_id = Uuid::new_v4(); + crate::api::broadcast::forward_to_broadcaster( + &state.broadcaster_url, + "channet_payload.zip", + wav_bytes, + tx_id, + ) + .await?; + + info!(%tx_id, zip_bytes = zip_len, "POST /chan/request transmitted successfully"); + + Ok(Json(ChanRequestResponse { + status: "transmitted", + tx_id, + zip_bytes: zip_len, + })) +} +``` + +```bash +cargo check +``` + +**Commit:** `feat: add api/chan.rs — ChanNet client and /chan/request handler` + +--- + +```rust +//! RustWave HTTP API server. +//! +//! full_router() — /wave/* + /broadcast/* + /chan/* (serve subcommand) +//! gui_router() — /broadcast/* + /chan/* (gui subcommand) + +pub mod broadcast; +pub mod chan; +pub mod errors; +pub mod models; +pub mod state; +pub mod wave; + +use axum::{routing::get, routing::post, Router}; +use std::net::SocketAddr; +use tower_http::limit::RequestBodyLimitLayer; +use tracing::info; + +use state::AppState; + +const BODY_LIMIT: usize = 10 * 1024 * 1024; // 10 MB + +pub fn full_router(state: AppState) -> Router { + let wave_routes = Router::new() + .route("/wave/status", get(wave::wave_status)) + .route("/wave/encode", post(wave::wave_encode)) + .route("/wave/decode", post(wave::wave_decode)); + + Router::new() + .merge(wave_routes) + .merge(broadcast_routes()) + .merge(chan_routes()) + .layer(RequestBodyLimitLayer::new(BODY_LIMIT)) + .with_state(state) +} + +pub fn gui_router(state: AppState) -> Router { + Router::new() + .merge(broadcast_routes()) + .merge(chan_routes()) + .layer(RequestBodyLimitLayer::new(BODY_LIMIT)) + .with_state(state) +} + +fn broadcast_routes() -> Router { + Router::new() + .route("/broadcast/status", get(broadcast::broadcast_status)) + .route("/broadcast/transmit", post(broadcast::broadcast_transmit)) + .route("/broadcast/receive", post(broadcast::broadcast_receive)) + .route("/broadcast/incoming", get(broadcast::broadcast_incoming)) +} + +fn chan_routes() -> Router { + Router::new() + .route("/chan/request", post(chan::chan_request)) +} + +pub async fn run_server(router: Router, bind_addr: SocketAddr) -> anyhow::Result<()> { + let listener = tokio::net::TcpListener::bind(bind_addr).await?; + info!(addr = %bind_addr, "RustWave API server listening"); + axum::serve(listener, router).await?; + Ok(()) +} +``` + +> **Note:** `run_server` uses `anyhow::Result` — add `anyhow = "1"` to `Cargo.toml` if not already present. + +```bash +cargo check +``` + +**Commit:** `feat: add api/mod.rs — router builder and run_server` + +--- + +### Step 9 — `src/main.rs` *(EDIT — full replacement)* + +```rust +mod api; +mod config; +mod decoder; +mod encoder; +mod framer; +mod gui; +mod logging; +mod wav; + +use clap::{Parser, Subcommand}; +use std::{net::SocketAddr, path::PathBuf}; + +#[derive(Parser)] +#[command(name = "rustwave-cli", version, about = "RustWave audio codec", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Launch the drag-and-drop GUI (also starts /broadcast/* API on 127.0.0.1:7071) + Gui, + /// Start the HTTP API server (both /wave/* and /broadcast/* on 127.0.0.1:7071) + Serve { + #[arg(short, long, value_name = "ADDR")] + bind: Option, + }, + /// Encode a file into an AFSK WAV + Encode { + #[arg(short, long, value_name = "FILE")] + input: PathBuf, + #[arg(short, long, value_name = "FILE")] + output: PathBuf, + }, + /// Decode an AFSK WAV — restores the original filename automatically + Decode { + #[arg(short, long, value_name = "FILE")] + input: PathBuf, + #[arg(short, long, value_name = "FILE")] + output: Option, + }, +} + +fn main() { + let _log_guard = logging::init(); + if let Err(e) = run() { + eprintln!("error: {e}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), String> { + let cli = Cli::parse(); + + match cli.command { + Command::Gui => { + gui::run().map_err(|e| format!("GUI error: {e}"))?; + } + + Command::Serve { bind } => { + let addr = bind + .or_else(|| std::env::var("RUSTWAVE_BIND").ok().and_then(|s| s.parse().ok())) + .unwrap_or_else(|| "127.0.0.1:7071".parse().unwrap()); + + let rt = tokio::runtime::Runtime::new() + .map_err(|e| format!("failed to build Tokio runtime: {e}"))?; + + rt.block_on(async move { + let state = api::state::AppState::new(true); + let router = api::full_router(state); + api::run_server(router, addr) + .await + .map_err(|e| format!("server error: {e}")) + })?; + } + + Command::Encode { input, output } => { + let data = std::fs::read(&input) + .map_err(|e| format!("cannot read '{}': {e}", input.display()))?; + let filename = input.file_name().unwrap_or_default().to_string_lossy().into_owned(); + let framed = framer::frame(&data, &filename); + let samples = encoder::encode(&framed); + wav::write(&output, &samples) + .map_err(|e| format!("cannot write '{}': {e}", output.display()))?; + #[allow(clippy::cast_precision_loss)] + let duration = samples.len() as f64 / f64::from(config::SAMPLE_RATE); + eprintln!("encoded '{}' ({} byte{}) -> {} ({duration:.2} s)", + filename, data.len(), plural(data.len()), output.display()); + } + + Command::Decode { input, output } => { + let samples = wav::read(&input) + .map_err(|e| format!("cannot read '{}': {e}", input.display()))?; + let decoded = decoder::decode(&samples).map_err(|e| format!("decode failed: {e}"))?; + let out_path = output.unwrap_or_else(|| { + input.parent().unwrap_or_else(|| std::path::Path::new(".")).join(&decoded.filename) + }); + std::fs::write(&out_path, &decoded.data) + .map_err(|e| format!("cannot write '{}': {e}", out_path.display()))?; + eprintln!("decoded {} byte{} -> '{}' (original filename: '{}')", + decoded.data.len(), plural(decoded.data.len()), out_path.display(), decoded.filename); + } + } + + Ok(()) +} + +const fn plural(n: usize) -> &'static str { + if n == 1 { "" } else { "s" } +} +``` + +```bash +cargo check +cargo test +./target/debug/rustwave-cli --help +``` + +**Commit:** `feat: add serve subcommand and wire logging in main.rs` + +--- + +### Step 10 — `src/gui.rs` *(EDIT — replace `pub fn run()` only)* + +Find the existing `pub fn run() -> eframe::Result<()>` function at the bottom of `gui.rs` and replace it with: + +```rust +pub fn run() -> eframe::Result<()> { + // Spawn the /broadcast/* API server on a background OS thread. + std::thread::spawn(|| { + let addr: std::net::SocketAddr = std::env::var("RUSTWAVE_BIND") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or_else(|| "127.0.0.1:7071".parse().unwrap()); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to build Tokio runtime for GUI API server"); + + rt.block_on(async move { + let state = crate::api::state::AppState::new(false); + let router = crate::api::gui_router(state); + if let Err(e) = crate::api::run_server(router, addr).await { + tracing::error!("GUI API server error: {e}"); + } + }); + }); + + tracing::info!("GUI mode: /broadcast/* API started on 127.0.0.1:7071"); + + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([480.0, 340.0]) + .with_min_inner_size([360.0, 260.0]) + .with_title("RustWave") + .with_drag_and_drop(true), + ..Default::default() + }; + + eframe::run_native( + "RustWave", + options, + Box::new(|cc| Ok(Box::new(AfskGui::new(cc)) as Box)), + ) +} +``` + +**No other changes to `gui.rs`.** All existing drag-and-drop, progress bar, and encode/decode logic is untouched. + +```bash +cargo check +cargo clippy +``` + +**Commit:** `feat: spawn broadcast API server in GUI mode` + +--- + +### Step 11 — `src/wav.rs` *(EDIT — append before `#[cfg(test)]`)* + +Add to the bottom of `src/wav.rs`, before the existing `#[cfg(test)]` block: + +```rust +// ── In-memory variants used by the HTTP API ────────────────────────────── + +pub fn write_to_bytes(samples: &[f64]) -> Result, String> { + use std::io::Cursor; + let spec = hound::WavSpec { + channels: 1, + sample_rate: SAMPLE_RATE, + bits_per_sample: 16, + sample_format: hound::SampleFormat::Int, + }; + + let mut buf: Vec = Vec::new(); + let cursor = Cursor::new(&mut buf); + let mut writer = hound::WavWriter::new(cursor, spec).map_err(|e| e.to_string())?; + + for &s in samples { + #[allow(clippy::cast_possible_truncation)] + let v = (s.clamp(-1.0, 1.0) * 32_767.0) as i16; + writer.write_sample(v).map_err(|e| e.to_string())?; + } + + writer.finalize().map_err(|e| e.to_string())?; + Ok(buf) +} + +pub fn read_from_bytes(data: &[u8]) -> Result, String> { + use std::io::Cursor; + let cursor = Cursor::new(data); + let mut reader = hound::WavReader::new(cursor).map_err(|e| e.to_string())?; + let spec = reader.spec(); + + match (spec.bits_per_sample, spec.sample_format) { + (16, hound::SampleFormat::Int) => { + let channels = usize::from(spec.channels); + if channels == 0 { + return Err("invalid WAV: 0 channels".into()); + } + reader + .samples::() + .step_by(channels) + .map(|s| s.map(|v| f64::from(v) / 32_768.0).map_err(|e| e.to_string())) + .collect() + } + (bits, fmt) => Err(format!( + "unsupported WAV format: {bits}-bit {fmt:?} (rustwave-cli expects 16-bit integer PCM)" + )), + } +} +``` + +Also add inside the existing `#[cfg(test)]` block: + +```rust +#[test] +fn memory_round_trip() -> Result<(), String> { + use std::f64::consts::TAU; + #[allow(clippy::cast_precision_loss)] + let original: Vec = (0..4_410_i32) + .map(|i| 0.5 * (TAU * 440.0 * f64::from(i) / 44_100.0).sin()) + .collect(); + let bytes = write_to_bytes(&original)?; + let recovered = read_from_bytes(&bytes)?; + assert_eq!(original.len(), recovered.len()); + for (a, b) in original.iter().zip(recovered.iter()) { + assert!((a - b).abs() < 5e-5, "quantisation error: {a} vs {b}"); + } + Ok(()) +} +``` + +```bash +cargo check +cargo test +``` + +**Commit:** `feat: add wav::write_to_bytes and read_from_bytes for API use` + +--- + +### Step 12 — `src/api/tests.rs` *(CREATE)* + +```rust +#[cfg(test)] +mod tests { + use crate::api::state::AppState; + + #[tokio::test] + async fn queue_enqueue_dequeue() { + use crate::api::state::QueuedFile; + use bytes::Bytes; + use uuid::Uuid; + + let state = AppState::new(false); + assert_eq!(state.queue_depth().await, 0); + + state.enqueue(QueuedFile { + queued_id: Uuid::new_v4(), + bytes: Bytes::from_static(b"hello"), + }).await; + + assert_eq!(state.queue_depth().await, 1); + let file = state.dequeue().await.unwrap(); + assert_eq!(file.bytes.as_ref(), b"hello"); + assert!(state.dequeue().await.is_none()); + } +} +``` + +```bash +cargo test +``` + +**Commit:** `test: add integration test script and api/tests.rs` + +--- + +## Final Verification + +```bash +cargo deny check +cargo build --release +git tag v0.2.0-api +``` + +--- + +## Quick Reference — Route Table + +| Mode | Route | Method | Registered? | +|---|---|---|---| +| `serve` | `/wave/status` | GET | ✓ | +| `serve` | `/wave/encode` | POST | ✓ | +| `serve` | `/wave/decode` | POST | ✓ | +| `serve` + `gui` | `/broadcast/status` | GET | ✓ | +| `serve` + `gui` | `/broadcast/transmit` | POST | ✓ | +| `serve` + `gui` | `/broadcast/receive` | POST | ✓ | +| `serve` + `gui` | `/broadcast/incoming` | GET | ✓ | +| `serve` + `gui` | `/chan/request` | POST | ✓ | +| `gui` | `/wave/*` | any | ✗ (404) | + +**ChanNet ↔ RustWave call directions:** +- *ChanNet → RustWave:* ChanNet's `/chan/refresh` posts ZIP snapshots to `POST /broadcast/transmit`; ChanNet's `/chan/poll` reads decoded inbound payloads from `GET /broadcast/incoming`. No new routes are needed for this direction. +- *RustWave → ChanNet:* `POST /chan/request` accepts a `ChanCommand` JSON body, forwards it to ChanNet's `/chan/command`, AFSK-encodes the returned ZIP, and forwards the WAV to the external Broadcaster for over-air transmission. + +## Environment Variables + +| Variable | Default | Description | +|---|---|---| +| `RUSTWAVE_BIND` | `127.0.0.1:7071` | API server bind address | +| `RUSTWAVE_BROADCASTER_URL` | `http://localhost:9090` | URL to forward encoded WAV to | +| `RUSTWAVE_CHANNET_URL` | `http://localhost:7070` | Base URL of the paired ChanNet node | +| `RUSTWAVE_LOG` | `info` | stderr log filter (tracing-subscriber syntax) | diff --git a/rustwave-api/rustwave_api_staged_build_prompt.md b/rustwave-api/rustwave_api_staged_build_prompt.md new file mode 100644 index 0000000..42a8c11 --- /dev/null +++ b/rustwave-api/rustwave_api_staged_build_prompt.md @@ -0,0 +1,1462 @@ +# RustWave HTTP API — Staged Build Prompt +*Synthesised from `rustwave_api_build_plan.md` + `RustWave-tree-annotated.txt` · March 2026* + +--- + +## How to Use This Document + +Hand this file (or a single stage from it) to an AI coding assistant. Each stage ends with a mandatory `cargo check` gate — do **not** proceed until it is green. Commit after every stage. The stages are ordered to keep the dependency graph clean: types before implementations, infrastructure before handlers, handlers before integration. + +--- + +## Project Context + +RustWave is a Rust application that AFSK-encodes arbitrary file payloads into 1 200-baud WAV audio and decodes them back. The existing codebase has: + +- `src/config.rs` — shared constants (44 100 Hz, 1 200 baud, MARK/SPACE frequencies) +- `src/encoder.rs` — bytes → PCM sine-wave synthesis +- `src/decoder.rs` — PCM → bytes via Goertzel-filter bit detection +- `src/framer.rs` — wire-frame builder/parser (preamble + sync + CRC-16/CCITT) +- `src/wav.rs` — WAV I/O via `hound` (file-based only, today) +- `src/gui.rs` — egui drag-and-drop front-end +- `src/main.rs` — CLI entry point (`gui`, `encode`, `decode` subcommands) + +**Goal:** Add an axum HTTP API with three route groups: + +| Group | Routes | Available in | +|---|---|---| +| `/wave/*` | encode, decode, status | `serve` mode only | +| `/broadcast/*` | transmit, receive, incoming, status | `serve` + `gui` | +| `/chan/*` | request (ChanNet proxy) | `serve` + `gui` | + +**Files that must NOT be touched:** `src/config.rs`, `src/encoder.rs`, `src/decoder.rs`, `src/framer.rs`, `deny.toml`. + +--- + +## Environment Variables (reference throughout) + +| Variable | Default | Description | +|---|---|---| +| `RUSTWAVE_BIND` | `127.0.0.1:7071` | API server bind address | +| `RUSTWAVE_BROADCASTER_URL` | `http://localhost:9090` | URL to forward encoded WAV to | +| `RUSTWAVE_CHANNET_URL` | `http://localhost:7070` | Base URL of the paired ChanNet node | +| `RUSTWAVE_LOG` | `info` | stderr log filter (tracing-subscriber syntax) | + +--- + +## Final Route Table (reference throughout) + +| Mode | Route | Method | +|---|---|---| +| `serve` | `/wave/status` | GET | +| `serve` | `/wave/encode` | POST multipart | +| `serve` | `/wave/decode` | POST multipart | +| `serve` + `gui` | `/broadcast/status` | GET | +| `serve` + `gui` | `/broadcast/transmit` | POST multipart | +| `serve` + `gui` | `/broadcast/receive` | POST multipart | +| `serve` + `gui` | `/broadcast/incoming` | GET | +| `serve` + `gui` | `/chan/request` | POST JSON | +| `gui` | `/wave/*` | 404 | + +--- + +--- + +# STAGE 1 — Dependencies (`Cargo.toml`) + +**What:** Add all new crate dependencies. Nothing else changes. + +**Why first:** Every subsequent stage imports these crates; the project won't compile at all without them. + +Replace the entire `[dependencies]` block in `Cargo.toml` with: + +```toml +[package] +name = "rustwave" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "RustWave audio codec — encode bytes to WAV, decode WAV to bytes" + +[[bin]] +name = "rustwave-cli" +path = "src/main.rs" + +[dependencies] +# — existing — +clap = { version = "4", features = ["derive"] } +hound = "3" +eframe = "0.31" + +# — NEW: async runtime (required for axum) — +tokio = { version = "1", features = ["full"] } + +# — NEW: HTTP server — +axum = { version = "0.7", features = ["multipart"] } +tower = "0.4" +tower-http = { version = "0.5", features = ["cors", "limit"] } + +# — NEW: HTTP client (forward WAV to Broadcaster) — +reqwest = { version = "0.12", features = ["multipart"] } + +# — NEW: serialisation — +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# — NEW: UUID generation for tx_id / queued_id — +uuid = { version = "1", features = ["v4"] } + +# — NEW: logging / tracing — +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +tracing-appender = "0.2" + +# — NEW: in-memory byte buffers (WAV bytes without temp files) — +bytes = "1" + +# — NEW: error propagation in run_server — +anyhow = "1" +``` + +```bash +cargo check # must be green before continuing +``` + +**Commit:** `chore: add API dependencies to Cargo.toml` + +--- + +--- + +# STAGE 2 — Logging (`src/logging.rs`) — CREATE + +**What:** Logging initialisation module. Writes human-readable INFO+ to stderr and rolling JSON DEBUG+ to a daily log file. Respects `RUSTWAVE_LOG` env var. + +**Why here:** `logging::init()` will be called at the top of `main()`. It must exist before `main.rs` is edited. + +Create `src/logging.rs`: + +```rust +//! Logging initialisation for RustWave. +//! +//! Call `logging::init()` once at the start of `main()`. +//! +//! Log output: +//! - stderr: INFO and above, human-readable +//! - rustwave.log (file): DEBUG and above, JSON format, rolling daily +//! +//! The log file is written next to the binary. +//! Set RUSTWAVE_LOG=debug to see debug output on stderr too. + +use std::path::PathBuf; +use tracing_appender::rolling; +use tracing_subscriber::{ + fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, +}; + +/// Initialise logging. Must be called once before any tracing macros are used. +/// +/// Returns the `_guard` from `tracing_appender::non_blocking`. The caller MUST +/// hold this value for the lifetime of the process; dropping it flushes and +/// closes the log file. +pub fn init() -> tracing_appender::non_blocking::WorkerGuard { + let log_dir: PathBuf = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(PathBuf::from)) + .unwrap_or_else(|| PathBuf::from(".")); + + // Rolling daily log file: rustwave.YYYY-MM-DD + let file_appender = rolling::daily(&log_dir, "rustwave.log"); + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + + // stderr layer — human readable, INFO+ by default, respects RUSTWAVE_LOG + let stderr_filter = EnvFilter::try_from_env("RUSTWAVE_LOG") + .unwrap_or_else(|_| EnvFilter::new("info")); + + let stderr_layer = fmt::layer() + .with_target(false) + .with_filter(stderr_filter); + + // file layer — JSON, DEBUG+ + let file_layer = fmt::layer() + .json() + .with_writer(non_blocking) + .with_filter(EnvFilter::new("debug")); + + tracing_subscriber::registry() + .with(stderr_layer) + .with(file_layer) + .init(); + + guard +} +``` + +```bash +cargo check +``` + +**Commit:** `feat: add logging module` + +--- + +--- + +# STAGE 3 — API Types (`src/api/models.rs`) — CREATE + +**What:** All JSON request and response structs for every API route. Also defines `ChanCommand`, the tagged enum that mirrors ChanNet's `/chan/command` API exactly. + +**Why here:** Every subsequent API module (`errors`, `state`, `wave`, `broadcast`, `chan`) imports from `models`. It must exist before any of them. + +```bash +mkdir src/api +``` + +Create `src/api/models.rs`: + +```rust +//! JSON request and response types for the RustWave API. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// ── /wave/* responses ────────────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct WaveStatusResponse { + pub service: &'static str, + pub codec: &'static str, + pub version: &'static str, +} + +// ── /broadcast/* responses ───────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct BroadcastStatusResponse { + pub service: &'static str, + pub broadcaster_connected: bool, + pub channet_connected: bool, + pub broadcaster_url: String, + pub queue_depth: usize, +} + +#[derive(Serialize)] +pub struct TransmitResponse { + pub status: &'static str, + pub tx_id: Uuid, + pub wav_bytes: usize, +} + +#[derive(Serialize)] +pub struct ReceiveResponse { + pub status: &'static str, + pub queued_id: Uuid, + pub decoded_bytes: usize, +} + +/// Returned by GET /broadcast/incoming when the queue is empty. +#[derive(Serialize, Deserialize)] +pub struct QueueEmptyResponse { + pub status: &'static str, +} + +// ── ChanNet /chan/command request types ──────────────────────────────────── +// +// Mirrors the six commands defined in the ChanNet API reference exactly. +// The `type` field is serialised as the serde tag so the JSON sent to +// /chan/command matches the format ChanNet expects. + +#[derive(Serialize, Deserialize, Debug)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ChanCommand { + /// All boards + all active (non-archived) posts. Optional delta via `since`. + FullExport { since: Option }, + /// All active posts on a single board. Optional delta via `since`. + BoardExport { board: String, since: Option }, + /// All posts in a single thread. Optional delta via `since`. + ThreadExport { thread_id: u64, since: Option }, + /// All archived threads + posts for a single board. Always a full export. + ArchiveExport { board: String }, + /// Entire database — all boards, threads, archives, posts. Use for initial + /// sync or recovery only; RustChan logs a warning when this is received. + ForceRefresh, + /// Post a new reply to an existing thread (the only write command). + ReplyPush { + board: String, + thread_id: u64, + author: String, + content: String, + timestamp: u64, + }, +} + +/// Returned by POST /chan/request on success. +#[derive(Serialize)] +pub struct ChanRequestResponse { + pub status: &'static str, // "transmitted" + pub tx_id: uuid::Uuid, + pub zip_bytes: usize, +} + +// ── Error envelope ───────────────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct ErrorDetail { + pub code: String, + pub message: String, + pub status: u16, +} + +#[derive(Serialize)] +pub struct ErrorEnvelope { + pub error: ErrorDetail, +} +``` + +```bash +cargo check +``` + +**Commit:** `feat: add api/models.rs` + +--- + +--- + +# STAGE 4 — Error Handling (`src/api/errors.rs`) — CREATE + +**What:** `ApiError` enum covering every failure mode. Implements axum's `IntoResponse` so handlers can return `Result<_, ApiError>` directly. Logs every error via `tracing`. + +**Why here:** Every handler module uses `ApiError`. It must be defined before the handlers. + +Create `src/api/errors.rs`: + +```rust +//! API error type for RustWave. +//! +//! Every handler returns `Result<_, ApiError>`. axum automatically calls +//! `IntoResponse` on the error path. + +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use crate::api::models::{ErrorDetail, ErrorEnvelope}; + +#[derive(Debug)] +pub enum ApiError { + BadRequest(String), + PayloadTooLarge, + EncodeFailed(String), + DecodeFailed(String), + BroadcasterUnavailable(String), + Internal(String), +} + +impl ApiError { + fn code(&self) -> &'static str { + match self { + Self::BadRequest(_) => "BAD_REQUEST", + Self::PayloadTooLarge => "PAYLOAD_TOO_LARGE", + Self::EncodeFailed(_) => "ENCODE_FAILED", + Self::DecodeFailed(_) => "DECODE_FAILED", + Self::BroadcasterUnavailable(_) => "BROADCASTER_UNAVAILABLE", + Self::Internal(_) => "INTERNAL_ERROR", + } + } + + fn status_code(&self) -> StatusCode { + match self { + Self::BadRequest(_) => StatusCode::BAD_REQUEST, + Self::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE, + Self::EncodeFailed(_) => StatusCode::UNPROCESSABLE_ENTITY, + Self::DecodeFailed(_) => StatusCode::UNPROCESSABLE_ENTITY, + Self::BroadcasterUnavailable(_) => StatusCode::BAD_GATEWAY, + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let status = self.status_code(); + let message = match &self { + Self::BadRequest(m) => m.clone(), + Self::PayloadTooLarge => "Request body exceeds the 10 MB limit.".into(), + Self::EncodeFailed(m) => m.clone(), + Self::DecodeFailed(m) => m.clone(), + Self::BroadcasterUnavailable(m) => m.clone(), + Self::Internal(m) => m.clone(), + }; + + tracing::error!( + code = self.code(), + http_status = status.as_u16(), + %message, + "api error" + ); + + let body = ErrorEnvelope { + error: ErrorDetail { + code: self.code().into(), + message, + status: status.as_u16(), + }, + }; + + (status, Json(body)).into_response() + } +} +``` + +```bash +cargo check +``` + +**Commit:** `feat: add api/errors.rs` + +--- + +--- + +# STAGE 5 — Shared State (`src/api/state.rs`) — CREATE + +**What:** `AppState` (holds broadcaster/channet URLs and the incoming queue), `IncomingQueue` type alias, and `QueuedFile`. `AppState` is `Clone` and is injected into every axum handler via `State`. + +**Why here:** The router (`mod.rs`) and every handler depend on `AppState`. Define it before either. + +Create `src/api/state.rs`: + +```rust +//! Shared state for the RustWave API server. + +use std::{collections::VecDeque, sync::Arc}; +use bytes::Bytes; +use tokio::sync::Mutex; +use uuid::Uuid; + +#[derive(Debug)] +pub struct QueuedFile { + pub queued_id: Uuid, + pub bytes: Bytes, +} + +pub type IncomingQueue = Arc>>; + +#[derive(Clone)] +pub struct AppState { + pub broadcaster_url: String, + pub channet_url: String, + pub wave_routes_enabled: bool, + pub incoming_queue: IncomingQueue, +} + +impl AppState { + pub fn new(wave_routes_enabled: bool) -> Self { + let broadcaster_url = std::env::var("RUSTWAVE_BROADCASTER_URL") + .unwrap_or_else(|_| "http://localhost:9090".to_string()); + + let channet_url = std::env::var("RUSTWAVE_CHANNET_URL") + .unwrap_or_else(|_| "http://localhost:7070".to_string()); + + Self { + broadcaster_url, + channet_url, + wave_routes_enabled, + incoming_queue: Arc::new(Mutex::new(VecDeque::new())), + } + } + + pub async fn queue_depth(&self) -> usize { + self.incoming_queue.lock().await.len() + } + + pub async fn enqueue(&self, file: QueuedFile) { + self.incoming_queue.lock().await.push_back(file); + } + + pub async fn dequeue(&self) -> Option { + self.incoming_queue.lock().await.pop_front() + } +} +``` + +```bash +cargo check +``` + +**Commit:** `feat: add api/state.rs` + +--- + +--- + +# STAGE 6 — Wave Handlers (`src/api/wave.rs`) — CREATE + +**What:** Three handlers for the general-purpose codec endpoints. Only mounted in `serve` mode (not GUI). All CPU-bound work (framing, encoding, decoding) is offloaded to `tokio::task::spawn_blocking`. + +**Routes implemented:** +- `GET /wave/status` → JSON version info +- `POST /wave/encode` → multipart file in → WAV bytes out +- `POST /wave/decode` → multipart WAV in → original file bytes out + +**Dependency note:** This module calls `wav::write_to_bytes` and `wav::read_from_bytes`, which do not exist yet in `wav.rs`. The file will not fully compile until Stage 11 adds those functions. However, it will pass `cargo check` because the functions are declared in scope — they just need to be added to `wav.rs` before `cargo test` and `cargo build` will succeed. + +Create `src/api/wave.rs`: + +```rust +//! Handlers for the /wave/* general-purpose codec endpoints. +//! Only registered in `serve` mode — NOT in GUI mode. + +use axum::{ + extract::Multipart, + http::header, + response::{IntoResponse, Response}, + Json, +}; +use tracing::{info, warn}; + +use crate::{ + api::{errors::ApiError, models::WaveStatusResponse}, + decoder, encoder, framer, wav, +}; + +// ── GET /wave/status ─────────────────────────────────────────────────────── + +pub async fn wave_status() -> Json { + info!("GET /wave/status"); + Json(WaveStatusResponse { + service: "rustwave", + codec: "afsk-1200", + version: env!("CARGO_PKG_VERSION"), + }) +} + +// ── POST /wave/encode ────────────────────────────────────────────────────── + +pub async fn wave_encode(mut multipart: Multipart) -> Result { + let (filename, file_bytes) = extract_file_field(&mut multipart).await?; + + info!(filename = %filename, input_bytes = file_bytes.len(), "POST /wave/encode starting"); + + let result = tokio::task::spawn_blocking(move || { + let framed = framer::frame(&file_bytes, &filename); + let samples = encoder::encode(&framed); + let wav_bytes = wav::write_to_bytes(&samples)?; + Ok::<(String, Vec), String>((filename, wav_bytes)) + }) + .await + .map_err(|e| ApiError::Internal(format!("task panic: {e}")))? + .map_err(ApiError::EncodeFailed)?; + + let (original_filename, wav_bytes) = result; + let stem = std::path::Path::new(&original_filename) + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + let out_name = format!("{stem}_encoded.wav"); + + info!(output_filename = %out_name, wav_bytes = wav_bytes.len(), "POST /wave/encode complete"); + + Ok(( + [ + (header::CONTENT_TYPE, "audio/wav"), + (header::CONTENT_DISPOSITION, &format!("attachment; filename=\"{out_name}\"")), + ], + wav_bytes, + ) + .into_response()) +} + +// ── POST /wave/decode ────────────────────────────────────────────────────── + +pub async fn wave_decode(mut multipart: Multipart) -> Result { + let (_field_name, wav_bytes) = extract_file_field(&mut multipart).await?; + + info!(wav_bytes = wav_bytes.len(), "POST /wave/decode starting"); + + let result = tokio::task::spawn_blocking(move || { + let samples = wav::read_from_bytes(&wav_bytes)?; + let decoded = decoder::decode(&samples)?; + Ok::(decoded) + }) + .await + .map_err(|e| ApiError::Internal(format!("task panic: {e}")))? + .map_err(ApiError::DecodeFailed)?; + + info!( + original_filename = %result.filename, + decoded_bytes = result.data.len(), + "POST /wave/decode complete" + ); + + Ok(( + [ + (header::CONTENT_TYPE, "application/octet-stream"), + (header::CONTENT_DISPOSITION, &format!("attachment; filename=\"{}\"", result.filename)), + ], + result.data, + ) + .into_response()) +} + +// ── Shared helper ────────────────────────────────────────────────────────── + +async fn extract_file_field( + multipart: &mut Multipart, +) -> Result<(String, Vec), ApiError> { + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| ApiError::BadRequest(format!("multipart error: {e}")))? + { + let filename = field.file_name().unwrap_or("upload").to_string(); + let data = field + .bytes() + .await + .map_err(|e| ApiError::BadRequest(format!("could not read field bytes: {e}")))?; + + if data.is_empty() { + warn!(filename = %filename, "received empty file field"); + return Err(ApiError::BadRequest("file field is empty".into())); + } + + return Ok((filename, data.to_vec())); + } + + Err(ApiError::BadRequest("no file field found in multipart body".into())) +} +``` + +```bash +cargo check +``` + +**Commit:** `feat: add api/wave.rs handlers` + +--- + +--- + +# STAGE 7 — Broadcast Handlers (`src/api/broadcast.rs`) — CREATE + +**What:** Four handlers for the broadcast channel endpoints, available in both `serve` and `gui` modes. Also exports `forward_to_broadcaster()`, a reusable helper that sends a WAV file to the external Broadcaster service via multipart POST. This function is called by both `broadcast_transmit` and `chan_request`. + +**Routes implemented:** +- `GET /broadcast/status` → JSON connectivity report (probes Broadcaster + ChanNet) +- `POST /broadcast/transmit` → file in → AFSK-encode → forward WAV to Broadcaster +- `POST /broadcast/receive` → WAV in → AFSK-decode → enqueue decoded bytes +- `GET /broadcast/incoming` → dequeue one decoded file, or `{"status":"empty"}` + +**ChanNet ↔ RustWave call direction note:** +- ChanNet **→** RustWave: ChanNet's `/chan/refresh` posts ZIP snapshots to `POST /broadcast/transmit`; ChanNet's `/chan/poll` reads decoded payloads from `GET /broadcast/incoming`. +- RustWave **→** ChanNet: handled by Stage 7.5 (`chan.rs`). + +Create `src/api/broadcast.rs`: + +```rust +//! Handlers for the /broadcast/* channel network endpoints. +//! Exposed in both `serve` mode and `gui` mode. + +use axum::{ + extract::{Multipart, State}, + http::{header, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use bytes::Bytes; +use tracing::{debug, info, warn}; +use uuid::Uuid; + +use crate::{ + api::{ + errors::ApiError, + models::{BroadcastStatusResponse, QueueEmptyResponse, ReceiveResponse, TransmitResponse}, + state::{AppState, QueuedFile}, + }, + decoder, encoder, framer, wav, +}; + +// ── GET /broadcast/status ────────────────────────────────────────────────── + +pub async fn broadcast_status(State(state): State) -> Json { + let queue_depth = state.queue_depth().await; + let broadcaster_connected = check_broadcaster_reachable(&state.broadcaster_url).await; + let channet_connected = crate::api::chan::check_channet_reachable(&state.channet_url).await; + + info!( + queue_depth, + broadcaster_connected, + channet_connected, + broadcaster_url = %state.broadcaster_url, + channet_url = %state.channet_url, + "GET /broadcast/status" + ); + + Json(BroadcastStatusResponse { + service: "rustwave", + broadcaster_connected, + channet_connected, + broadcaster_url: state.broadcaster_url.clone(), + queue_depth, + }) +} + +async fn check_broadcaster_reachable(url: &str) -> bool { + match reqwest::Client::new() + .get(url) + .timeout(std::time::Duration::from_secs(2)) + .send() + .await + { + Ok(r) => r.status().is_success(), + Err(_) => false, + } +} + +// ── POST /broadcast/transmit ─────────────────────────────────────────────── + +pub async fn broadcast_transmit( + State(state): State, + mut multipart: Multipart, +) -> Result, ApiError> { + let (filename, file_bytes) = extract_file_field(&mut multipart).await?; + + info!(filename = %filename, input_bytes = file_bytes.len(), "POST /broadcast/transmit received file"); + + let wav_bytes: Vec = tokio::task::spawn_blocking({ + let filename = filename.clone(); + move || { + let framed = framer::frame(&file_bytes, &filename); + let samples = encoder::encode(&framed); + wav::write_to_bytes(&samples) + } + }) + .await + .map_err(|e| ApiError::Internal(format!("task panic: {e}")))? + .map_err(ApiError::EncodeFailed)?; + + let wav_size = wav_bytes.len(); + let tx_id = Uuid::new_v4(); + + info!(%tx_id, wav_bytes = wav_size, broadcaster_url = %state.broadcaster_url, + "POST /broadcast/transmit forwarding to Broadcaster"); + + forward_to_broadcaster(&state.broadcaster_url, &filename, wav_bytes, tx_id).await?; + + Ok(Json(TransmitResponse { status: "ok", tx_id, wav_bytes: wav_size })) +} + +pub async fn forward_to_broadcaster( + broadcaster_url: &str, + original_filename: &str, + wav_bytes: Vec, + tx_id: Uuid, +) -> Result<(), ApiError> { + let stem = std::path::Path::new(original_filename) + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + let wav_filename = format!("{stem}_encoded.wav"); + + let part = reqwest::multipart::Part::bytes(wav_bytes) + .file_name(wav_filename) + .mime_str("audio/wav") + .map_err(|e| ApiError::Internal(format!("mime error: {e}")))?; + let form = reqwest::multipart::Form::new().part("file", part); + + let resp = reqwest::Client::new() + .post(broadcaster_url) + .multipart(form) + .send() + .await + .map_err(|e| ApiError::BroadcasterUnavailable( + format!("could not reach Broadcaster at {broadcaster_url}: {e}") + ))?; + + if !resp.status().is_success() { + return Err(ApiError::BroadcasterUnavailable(format!( + "Broadcaster returned HTTP {} for tx_id {tx_id}", resp.status() + ))); + } + + debug!(%tx_id, "Broadcaster accepted WAV"); + Ok(()) +} + +// ── POST /broadcast/receive ──────────────────────────────────────────────── + +pub async fn broadcast_receive( + State(state): State, + mut multipart: Multipart, +) -> Result, ApiError> { + let (_filename, wav_bytes) = extract_file_field(&mut multipart).await?; + + info!(wav_bytes = wav_bytes.len(), "POST /broadcast/receive decoding WAV"); + + let decoded = tokio::task::spawn_blocking(move || { + let samples = wav::read_from_bytes(&wav_bytes)?; + decoder::decode(&samples) + }) + .await + .map_err(|e| ApiError::Internal(format!("task panic: {e}")))? + .map_err(ApiError::DecodeFailed)?; + + let decoded_size = decoded.data.len(); + let queued_id = Uuid::new_v4(); + + info!( + %queued_id, + original_filename = %decoded.filename, + decoded_bytes = decoded_size, + "POST /broadcast/receive queuing decoded file" + ); + + state.enqueue(QueuedFile { queued_id, bytes: Bytes::from(decoded.data) }).await; + + Ok(Json(ReceiveResponse { status: "ok", queued_id, decoded_bytes: decoded_size })) +} + +// ── GET /broadcast/incoming ──────────────────────────────────────────────── + +pub async fn broadcast_incoming(State(state): State) -> Response { + match state.dequeue().await { + Some(file) => { + info!(queued_id = %file.queued_id, bytes = file.bytes.len(), + "GET /broadcast/incoming dequeuing file"); + ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, "application/octet-stream"), + (header::CONTENT_DISPOSITION, "attachment; filename=\"snapshot.zip\""), + ], + file.bytes, + ) + .into_response() + } + None => { + debug!("GET /broadcast/incoming queue is empty"); + (StatusCode::OK, Json(QueueEmptyResponse { status: "empty" })).into_response() + } + } +} + +// ── Shared helper ────────────────────────────────────────────────────────── + +async fn extract_file_field( + multipart: &mut Multipart, +) -> Result<(String, Vec), ApiError> { + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| ApiError::BadRequest(format!("multipart error: {e}")))? + { + let filename = field.file_name().unwrap_or("upload").to_string(); + let data = field + .bytes() + .await + .map_err(|e| ApiError::BadRequest(format!("could not read field bytes: {e}")))?; + + if data.is_empty() { + warn!(filename = %filename, "received empty file field"); + return Err(ApiError::BadRequest("file field is empty".into())); + } + + return Ok((filename, data.to_vec())); + } + + Err(ApiError::BadRequest("no file field found in multipart body".into())) +} +``` + +```bash +cargo check +``` + +**Commit:** `feat: add api/broadcast.rs handlers` + +--- + +--- + +# STAGE 7.5 — ChanNet Client (`src/api/chan.rs`) — CREATE + +**What:** The outbound direction from RustWave to ChanNet. Provides: +- `check_channet_reachable()` — probes `GET /chan/status`, called by `broadcast_status` +- `send_chan_command()` — POSTs a `ChanCommand` JSON to `/chan/command`, returns raw ZIP bytes +- `chan_request` handler — full pipeline: receive `ChanCommand` → fetch ZIP from ChanNet → AFSK-encode → forward WAV to Broadcaster + +**Route implemented:** `POST /chan/request` + +Create `src/api/chan.rs`: + +```rust +//! ChanNet HTTP client and /chan/request proxy handler. +//! +//! ChanNet already calls RustWave on: +//! POST /broadcast/transmit — pushes a ZIP snapshot for AFSK encoding & over-air transmission +//! GET /broadcast/incoming — pulls decoded ZIP snapshots that arrived over radio +//! +//! This module adds the outbound direction: +//! POST /chan/request — operator sends a typed ChanCommand; RustWave forwards it to +//! ChanNet's /chan/command, receives the ZIP response, AFSK-encodes +//! it into WAV, and calls forward_to_broadcaster() for transmission. + +use axum::{extract::State, Json}; +use tracing::info; +use uuid::Uuid; + +use crate::api::{ + errors::ApiError, + models::{ChanCommand, ChanRequestResponse}, + state::AppState, +}; +use crate::{encoder, framer, wav}; + +// ── Reachability probe ───────────────────────────────────────────────────── + +/// Hits ChanNet's GET /chan/status. Used by broadcast_status to report +/// whether the paired ChanNet node is reachable. +pub async fn check_channet_reachable(channet_url: &str) -> bool { + match reqwest::Client::new() + .get(format!("{channet_url}/chan/status")) + .timeout(std::time::Duration::from_secs(2)) + .send() + .await + { + Ok(r) => r.status().is_success(), + Err(_) => false, + } +} + +// ── ChanNet command client ───────────────────────────────────────────────── + +/// POST a typed ChanCommand to ChanNet's /chan/command endpoint. +/// Returns the raw ZIP bytes from the response body. +pub async fn send_chan_command( + channet_url: &str, + command: &ChanCommand, +) -> Result { + let resp = reqwest::Client::new() + .post(format!("{channet_url}/chan/command")) + .json(command) + .send() + .await + .map_err(|e| ApiError::BroadcasterUnavailable( + format!("ChanNet unreachable at {channet_url}: {e}") + ))?; + + if !resp.status().is_success() { + return Err(ApiError::BroadcasterUnavailable( + format!("ChanNet /chan/command returned HTTP {}", resp.status()), + )); + } + + resp.bytes() + .await + .map_err(|e| ApiError::Internal(format!("reading ChanNet response body: {e}"))) +} + +// ── POST /chan/request ───────────────────────────────────────────────────── + +pub async fn chan_request( + State(state): State, + Json(command): Json, +) -> Result, ApiError> { + info!(?command, channet_url = %state.channet_url, "POST /chan/request"); + + // 1. Fetch ZIP from ChanNet. + let zip_bytes = send_chan_command(&state.channet_url, &command).await?; + let zip_len = zip_bytes.len(); + + // 2. AFSK-encode the ZIP into WAV bytes (CPU-bound; run on blocking thread). + let wav_bytes: Vec = tokio::task::spawn_blocking(move || { + let framed = framer::frame(&zip_bytes, "channet_payload.zip"); + let samples = encoder::encode(&framed); + wav::write_to_bytes(&samples) + }) + .await + .map_err(|e| ApiError::Internal(format!("task panic: {e}")))? + .map_err(ApiError::EncodeFailed)?; + + // 3. Forward the WAV to the external Broadcaster for over-air transmission. + // Reuses the same helper as /broadcast/transmit. + let tx_id = Uuid::new_v4(); + crate::api::broadcast::forward_to_broadcaster( + &state.broadcaster_url, + "channet_payload.zip", + wav_bytes, + tx_id, + ) + .await?; + + info!(%tx_id, zip_bytes = zip_len, "POST /chan/request transmitted successfully"); + + Ok(Json(ChanRequestResponse { + status: "transmitted", + tx_id, + zip_bytes: zip_len, + })) +} +``` + +```bash +cargo check +``` + +**Commit:** `feat: add api/chan.rs — ChanNet client and /chan/request handler` + +--- + +--- + +# STAGE 8 — Router & Server (`src/api/mod.rs`) — CREATE + +**What:** Wires all handlers into two router variants and exposes `run_server`. The `10 MB` body limit is applied as a tower layer on both routers. + +- `full_router(state)` — `/wave/*` + `/broadcast/*` + `/chan/*` (used by `serve` subcommand) +- `gui_router(state)` — `/broadcast/*` + `/chan/*` only (used by GUI background thread) +- `run_server(router, addr)` — binds TCP, starts axum + +Create `src/api/mod.rs`: + +```rust +//! RustWave HTTP API server. +//! +//! full_router() — /wave/* + /broadcast/* + /chan/* (serve subcommand) +//! gui_router() — /broadcast/* + /chan/* (gui subcommand) + +pub mod broadcast; +pub mod chan; +pub mod errors; +pub mod models; +pub mod state; +pub mod wave; + +use axum::{routing::get, routing::post, Router}; +use std::net::SocketAddr; +use tower_http::limit::RequestBodyLimitLayer; +use tracing::info; + +use state::AppState; + +const BODY_LIMIT: usize = 10 * 1024 * 1024; // 10 MB + +pub fn full_router(state: AppState) -> Router { + let wave_routes = Router::new() + .route("/wave/status", get(wave::wave_status)) + .route("/wave/encode", post(wave::wave_encode)) + .route("/wave/decode", post(wave::wave_decode)); + + Router::new() + .merge(wave_routes) + .merge(broadcast_routes()) + .merge(chan_routes()) + .layer(RequestBodyLimitLayer::new(BODY_LIMIT)) + .with_state(state) +} + +pub fn gui_router(state: AppState) -> Router { + Router::new() + .merge(broadcast_routes()) + .merge(chan_routes()) + .layer(RequestBodyLimitLayer::new(BODY_LIMIT)) + .with_state(state) +} + +fn broadcast_routes() -> Router { + Router::new() + .route("/broadcast/status", get(broadcast::broadcast_status)) + .route("/broadcast/transmit", post(broadcast::broadcast_transmit)) + .route("/broadcast/receive", post(broadcast::broadcast_receive)) + .route("/broadcast/incoming", get(broadcast::broadcast_incoming)) +} + +fn chan_routes() -> Router { + Router::new() + .route("/chan/request", post(chan::chan_request)) +} + +pub async fn run_server(router: Router, bind_addr: SocketAddr) -> anyhow::Result<()> { + let listener = tokio::net::TcpListener::bind(bind_addr).await?; + info!(addr = %bind_addr, "RustWave API server listening"); + axum::serve(listener, router).await?; + Ok(()) +} +``` + +```bash +cargo check +``` + +**Commit:** `feat: add api/mod.rs — router builder and run_server` + +--- + +--- + +# STAGE 9 — CLI Entry Point (`src/main.rs`) — EDIT (full replacement) + +**What:** Replace `main.rs` entirely to add the `serve` subcommand, wire logging init, and declare the new `mod api` and `mod logging` modules. All existing `encode`, `decode`, and `gui` subcommand logic is preserved unchanged. + +**Why now:** This is the last structural edit before the two targeted file-level edits. It depends on everything above being in place. + +Replace `src/main.rs` with: + +```rust +mod api; +mod config; +mod decoder; +mod encoder; +mod framer; +mod gui; +mod logging; +mod wav; + +use clap::{Parser, Subcommand}; +use std::{net::SocketAddr, path::PathBuf}; + +#[derive(Parser)] +#[command(name = "rustwave-cli", version, about = "RustWave audio codec", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Launch the drag-and-drop GUI (also starts /broadcast/* API on 127.0.0.1:7071) + Gui, + /// Start the HTTP API server (both /wave/* and /broadcast/* on 127.0.0.1:7071) + Serve { + #[arg(short, long, value_name = "ADDR")] + bind: Option, + }, + /// Encode a file into an AFSK WAV + Encode { + #[arg(short, long, value_name = "FILE")] + input: PathBuf, + #[arg(short, long, value_name = "FILE")] + output: PathBuf, + }, + /// Decode an AFSK WAV — restores the original filename automatically + Decode { + #[arg(short, long, value_name = "FILE")] + input: PathBuf, + #[arg(short, long, value_name = "FILE")] + output: Option, + }, +} + +fn main() { + let _log_guard = logging::init(); + if let Err(e) = run() { + eprintln!("error: {e}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), String> { + let cli = Cli::parse(); + + match cli.command { + Command::Gui => { + gui::run().map_err(|e| format!("GUI error: {e}"))?; + } + + Command::Serve { bind } => { + let addr = bind + .or_else(|| std::env::var("RUSTWAVE_BIND").ok().and_then(|s| s.parse().ok())) + .unwrap_or_else(|| "127.0.0.1:7071".parse().unwrap()); + + let rt = tokio::runtime::Runtime::new() + .map_err(|e| format!("failed to build Tokio runtime: {e}"))?; + + rt.block_on(async move { + let state = api::state::AppState::new(true); + let router = api::full_router(state); + api::run_server(router, addr) + .await + .map_err(|e| format!("server error: {e}")) + })?; + } + + Command::Encode { input, output } => { + let data = std::fs::read(&input) + .map_err(|e| format!("cannot read '{}': {e}", input.display()))?; + let filename = input.file_name().unwrap_or_default().to_string_lossy().into_owned(); + let framed = framer::frame(&data, &filename); + let samples = encoder::encode(&framed); + wav::write(&output, &samples) + .map_err(|e| format!("cannot write '{}': {e}", output.display()))?; + #[allow(clippy::cast_precision_loss)] + let duration = samples.len() as f64 / f64::from(config::SAMPLE_RATE); + eprintln!("encoded '{}' ({} byte{}) -> {} ({duration:.2} s)", + filename, data.len(), plural(data.len()), output.display()); + } + + Command::Decode { input, output } => { + let samples = wav::read(&input) + .map_err(|e| format!("cannot read '{}': {e}", input.display()))?; + let decoded = decoder::decode(&samples).map_err(|e| format!("decode failed: {e}"))?; + let out_path = output.unwrap_or_else(|| { + input.parent().unwrap_or_else(|| std::path::Path::new(".")).join(&decoded.filename) + }); + std::fs::write(&out_path, &decoded.data) + .map_err(|e| format!("cannot write '{}': {e}", out_path.display()))?; + eprintln!("decoded {} byte{} -> '{}' (original filename: '{}')", + decoded.data.len(), plural(decoded.data.len()), out_path.display(), decoded.filename); + } + } + + Ok(()) +} + +const fn plural(n: usize) -> &'static str { + if n == 1 { "" } else { "s" } +} +``` + +```bash +cargo check +cargo test +./target/debug/rustwave-cli --help +``` + +**Commit:** `feat: add serve subcommand and wire logging in main.rs` + +--- + +--- + +# STAGE 10 — GUI API Thread (`src/gui.rs`) — EDIT (targeted) + +**What:** Spawn the `/broadcast/*` API server on a background OS thread before launching eframe. **No other changes to `gui.rs`.** All existing drag-and-drop, progress bar, and encode/decode logic is untouched. + +**How:** Find the existing `pub fn run() -> eframe::Result<()>` function at the bottom of `gui.rs` and replace it with the version below. + +```rust +pub fn run() -> eframe::Result<()> { + // Spawn the /broadcast/* API server on a background OS thread. + std::thread::spawn(|| { + let addr: std::net::SocketAddr = std::env::var("RUSTWAVE_BIND") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or_else(|| "127.0.0.1:7071".parse().unwrap()); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to build Tokio runtime for GUI API server"); + + rt.block_on(async move { + let state = crate::api::state::AppState::new(false); + let router = crate::api::gui_router(state); + if let Err(e) = crate::api::run_server(router, addr).await { + tracing::error!("GUI API server error: {e}"); + } + }); + }); + + tracing::info!("GUI mode: /broadcast/* API started on 127.0.0.1:7071"); + + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([480.0, 340.0]) + .with_min_inner_size([360.0, 260.0]) + .with_title("RustWave") + .with_drag_and_drop(true), + ..Default::default() + }; + + eframe::run_native( + "RustWave", + options, + Box::new(|cc| Ok(Box::new(AfskGui::new(cc)) as Box)), + ) +} +``` + +```bash +cargo check +cargo clippy +``` + +**Commit:** `feat: spawn broadcast API server in GUI mode` + +--- + +--- + +# STAGE 11 — In-Memory WAV I/O (`src/wav.rs`) — EDIT (append) + +**What:** Add `write_to_bytes` and `read_from_bytes` to `wav.rs`. These are the in-memory counterparts to the existing file-based `write`/`read` functions, used by every API handler to avoid temp files. Also adds a `memory_round_trip` unit test. + +**How:** Append the two functions **before** the existing `#[cfg(test)]` block, then add the test **inside** the existing `#[cfg(test)]` block. + +Append before `#[cfg(test)]`: + +```rust +// ── In-memory variants used by the HTTP API ────────────────────────────── + +pub fn write_to_bytes(samples: &[f64]) -> Result, String> { + use std::io::Cursor; + let spec = hound::WavSpec { + channels: 1, + sample_rate: SAMPLE_RATE, + bits_per_sample: 16, + sample_format: hound::SampleFormat::Int, + }; + + let mut buf: Vec = Vec::new(); + let cursor = Cursor::new(&mut buf); + let mut writer = hound::WavWriter::new(cursor, spec).map_err(|e| e.to_string())?; + + for &s in samples { + #[allow(clippy::cast_possible_truncation)] + let v = (s.clamp(-1.0, 1.0) * 32_767.0) as i16; + writer.write_sample(v).map_err(|e| e.to_string())?; + } + + writer.finalize().map_err(|e| e.to_string())?; + Ok(buf) +} + +pub fn read_from_bytes(data: &[u8]) -> Result, String> { + use std::io::Cursor; + let cursor = Cursor::new(data); + let mut reader = hound::WavReader::new(cursor).map_err(|e| e.to_string())?; + let spec = reader.spec(); + + match (spec.bits_per_sample, spec.sample_format) { + (16, hound::SampleFormat::Int) => { + let channels = usize::from(spec.channels); + if channels == 0 { + return Err("invalid WAV: 0 channels".into()); + } + reader + .samples::() + .step_by(channels) + .map(|s| s.map(|v| f64::from(v) / 32_768.0).map_err(|e| e.to_string())) + .collect() + } + (bits, fmt) => Err(format!( + "unsupported WAV format: {bits}-bit {fmt:?} (rustwave-cli expects 16-bit integer PCM)" + )), + } +} +``` + +Add inside the existing `#[cfg(test)]` block: + +```rust +#[test] +fn memory_round_trip() -> Result<(), String> { + use std::f64::consts::TAU; + #[allow(clippy::cast_precision_loss)] + let original: Vec = (0..4_410_i32) + .map(|i| 0.5 * (TAU * 440.0 * f64::from(i) / 44_100.0).sin()) + .collect(); + let bytes = write_to_bytes(&original)?; + let recovered = read_from_bytes(&bytes)?; + assert_eq!(original.len(), recovered.len()); + for (a, b) in original.iter().zip(recovered.iter()) { + assert!((a - b).abs() < 5e-5, "quantisation error: {a} vs {b}"); + } + Ok(()) +} +``` + +```bash +cargo check +cargo test +``` + +**Commit:** `feat: add wav::write_to_bytes and read_from_bytes for API use` + +--- + +--- + +# STAGE 12 — Queue Unit Test (`src/api/tests.rs`) — CREATE + +**What:** Async unit test for `AppState`'s queue enqueue/dequeue round-trip. Validates that `queue_depth`, `enqueue`, and `dequeue` behave correctly under a `#[tokio::test]` runtime. + +Create `src/api/tests.rs`: + +```rust +#[cfg(test)] +mod tests { + use crate::api::state::AppState; + + #[tokio::test] + async fn queue_enqueue_dequeue() { + use crate::api::state::QueuedFile; + use bytes::Bytes; + use uuid::Uuid; + + let state = AppState::new(false); + assert_eq!(state.queue_depth().await, 0); + + state.enqueue(QueuedFile { + queued_id: Uuid::new_v4(), + bytes: Bytes::from_static(b"hello"), + }).await; + + assert_eq!(state.queue_depth().await, 1); + let file = state.dequeue().await.unwrap(); + assert_eq!(file.bytes.as_ref(), b"hello"); + assert!(state.dequeue().await.is_none()); + } +} +``` + +Also add `pub mod tests;` to the bottom of `src/api/mod.rs`. + +```bash +cargo test +``` + +**Commit:** `test: add api/tests.rs queue round-trip test` + +--- + +--- + +# STAGE 13 — Final Verification + +Run the full suite before tagging: + +```bash +cargo deny check +cargo clippy -- -D warnings +cargo test +cargo build --release +./target/release/rustwave-cli --help +git tag v0.2.0-api +``` + +Expected: all green, binary prints help text showing `gui`, `serve`, `encode`, `decode` subcommands. + +--- + +## File Change Summary + +| File | Action | Stage | +|---|---|---| +| `Cargo.toml` | EDIT | 1 | +| `src/logging.rs` | CREATE | 2 | +| `src/api/models.rs` | CREATE | 3 | +| `src/api/errors.rs` | CREATE | 4 | +| `src/api/state.rs` | CREATE | 5 | +| `src/api/wave.rs` | CREATE | 6 | +| `src/api/broadcast.rs` | CREATE | 7 | +| `src/api/chan.rs` | CREATE | 7.5 | +| `src/api/mod.rs` | CREATE | 8 | +| `src/main.rs` | EDIT (full replace) | 9 | +| `src/gui.rs` | EDIT (`run()` only) | 10 | +| `src/wav.rs` | EDIT (append) | 11 | +| `src/api/tests.rs` | CREATE | 12 | diff --git a/src/api/broadcast.rs b/src/api/broadcast.rs new file mode 100644 index 0000000..87d88a1 --- /dev/null +++ b/src/api/broadcast.rs @@ -0,0 +1,213 @@ +//! Handlers for the /broadcast/* channel network endpoints. +//! Exposed in both `serve` mode and `gui` mode. + +use axum::{ + extract::{Multipart, State}, + http::{header, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use bytes::Bytes; +use tracing::{debug, info, warn}; +use uuid::Uuid; + +use crate::{ + api::{ + errors::ApiError, + models::{BroadcastStatusResponse, QueueEmptyResponse, ReceiveResponse, TransmitResponse}, + state::{AppState, QueuedFile}, + }, + decoder, encoder, framer, wav, +}; + +// ── GET /broadcast/status ────────────────────────────────────────────────── + +pub async fn broadcast_status(State(state): State) -> Json { + let queue_depth = state.queue_depth().await; + let broadcaster_connected = check_broadcaster_reachable(&state.broadcaster_url).await; + let channet_connected = crate::api::chan::check_channet_reachable(&state.channet_url).await; + + info!( + queue_depth, + broadcaster_connected, + channet_connected, + broadcaster_url = %state.broadcaster_url, + channet_url = %state.channet_url, + "GET /broadcast/status" + ); + + Json(BroadcastStatusResponse { + service: "rustwave", + broadcaster_connected, + channet_connected, + broadcaster_url: state.broadcaster_url.clone(), + queue_depth, + }) +} + +async fn check_broadcaster_reachable(url: &str) -> bool { + match reqwest::Client::new() + .get(url) + .timeout(std::time::Duration::from_secs(2)) + .send() + .await + { + Ok(r) => r.status().is_success(), + Err(_) => false, + } +} + +// ── POST /broadcast/transmit ─────────────────────────────────────────────── + +pub async fn broadcast_transmit( + State(state): State, + mut multipart: Multipart, +) -> Result, ApiError> { + let (filename, file_bytes) = extract_file_field(&mut multipart).await?; + + info!(filename = %filename, input_bytes = file_bytes.len(), "POST /broadcast/transmit received file"); + + let wav_bytes: Vec = tokio::task::spawn_blocking({ + let filename = filename.clone(); + move || { + let framed = framer::frame(&file_bytes, &filename); + let samples = encoder::encode(&framed); + wav::write_to_bytes(&samples) + } + }) + .await + .map_err(|e| ApiError::Internal(format!("task panic: {e}")))? + .map_err(ApiError::EncodeFailed)?; + + let wav_size = wav_bytes.len(); + let tx_id = Uuid::new_v4(); + + info!(%tx_id, wav_bytes = wav_size, broadcaster_url = %state.broadcaster_url, + "POST /broadcast/transmit forwarding to Broadcaster"); + + forward_to_broadcaster(&state.broadcaster_url, &filename, wav_bytes, tx_id).await?; + + Ok(Json(TransmitResponse { status: "ok", tx_id, wav_bytes: wav_size })) +} + +pub async fn forward_to_broadcaster( + broadcaster_url: &str, + original_filename: &str, + wav_bytes: Vec, + tx_id: Uuid, +) -> Result<(), ApiError> { + let stem = std::path::Path::new(original_filename) + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + let wav_filename = format!("{stem}_encoded.wav"); + + let part = reqwest::multipart::Part::bytes(wav_bytes) + .file_name(wav_filename) + .mime_str("audio/wav") + .map_err(|e| ApiError::Internal(format!("mime error: {e}")))?; + let form = reqwest::multipart::Form::new().part("file", part); + + let resp = reqwest::Client::new() + .post(broadcaster_url) + .multipart(form) + .send() + .await + .map_err(|e| ApiError::BroadcasterUnavailable( + format!("could not reach Broadcaster at {broadcaster_url}: {e}") + ))?; + + if !resp.status().is_success() { + return Err(ApiError::BroadcasterUnavailable(format!( + "Broadcaster returned HTTP {} for tx_id {tx_id}", resp.status() + ))); + } + + debug!(%tx_id, "Broadcaster accepted WAV"); + Ok(()) +} + +// ── POST /broadcast/receive ──────────────────────────────────────────────── + +pub async fn broadcast_receive( + State(state): State, + mut multipart: Multipart, +) -> Result, ApiError> { + let (_filename, wav_bytes) = extract_file_field(&mut multipart).await?; + + info!(wav_bytes = wav_bytes.len(), "POST /broadcast/receive decoding WAV"); + + let decoded = tokio::task::spawn_blocking(move || { + let samples = wav::read_from_bytes(&wav_bytes)?; + decoder::decode(&samples) + }) + .await + .map_err(|e| ApiError::Internal(format!("task panic: {e}")))? + .map_err(ApiError::DecodeFailed)?; + + let decoded_size = decoded.data.len(); + let queued_id = Uuid::new_v4(); + + info!( + %queued_id, + original_filename = %decoded.filename, + decoded_bytes = decoded_size, + "POST /broadcast/receive queuing decoded file" + ); + + state.enqueue(QueuedFile { queued_id, bytes: Bytes::from(decoded.data) }).await; + + Ok(Json(ReceiveResponse { status: "ok", queued_id, decoded_bytes: decoded_size })) +} + +// ── GET /broadcast/incoming ──────────────────────────────────────────────── + +pub async fn broadcast_incoming(State(state): State) -> Response { + match state.dequeue().await { + Some(file) => { + info!(queued_id = %file.queued_id, bytes = file.bytes.len(), + "GET /broadcast/incoming dequeuing file"); + ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, "application/octet-stream"), + (header::CONTENT_DISPOSITION, "attachment; filename=\"snapshot.zip\""), + ], + file.bytes, + ) + .into_response() + } + None => { + debug!("GET /broadcast/incoming queue is empty"); + (StatusCode::OK, Json(QueueEmptyResponse { status: "empty" })).into_response() + } + } +} + +// ── Shared helper ────────────────────────────────────────────────────────── + +async fn extract_file_field( + multipart: &mut Multipart, +) -> Result<(String, Vec), ApiError> { + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| ApiError::BadRequest(format!("multipart error: {e}")))? + { + let filename = field.file_name().unwrap_or("upload").to_string(); + let data = field + .bytes() + .await + .map_err(|e| ApiError::BadRequest(format!("could not read field bytes: {e}")))?; + + if data.is_empty() { + warn!(filename = %filename, "received empty file field"); + return Err(ApiError::BadRequest("file field is empty".into())); + } + + return Ok((filename, data.to_vec())); + } + + Err(ApiError::BadRequest("no file field found in multipart body".into())) +} diff --git a/src/api/chan.rs b/src/api/chan.rs new file mode 100644 index 0000000..33b21e6 --- /dev/null +++ b/src/api/chan.rs @@ -0,0 +1,107 @@ +//! ChanNet HTTP client and /chan/request proxy handler. +//! +//! ChanNet already calls RustWave on: +//! POST /broadcast/transmit — pushes a ZIP snapshot for AFSK encoding & over-air transmission +//! GET /broadcast/incoming — pulls decoded ZIP snapshots that arrived over radio +//! +//! This module adds the outbound direction: +//! POST /chan/request — operator sends a typed ChanCommand; RustWave forwards it to +//! ChanNet's /chan/command, receives the ZIP response, AFSK-encodes +//! it into WAV, and calls forward_to_broadcaster() for transmission. + +use axum::{extract::State, Json}; +use tracing::info; +use uuid::Uuid; + +use crate::api::{ + errors::ApiError, + models::{ChanCommand, ChanRequestResponse}, + state::AppState, +}; +use crate::{encoder, framer, wav}; + +// ── Reachability probe ───────────────────────────────────────────────────── + +/// Hits ChanNet's GET /chan/status. Used by broadcast_status to report +/// whether the paired ChanNet node is reachable. +pub async fn check_channet_reachable(channet_url: &str) -> bool { + match reqwest::Client::new() + .get(format!("{channet_url}/chan/status")) + .timeout(std::time::Duration::from_secs(2)) + .send() + .await + { + Ok(r) => r.status().is_success(), + Err(_) => false, + } +} + +// ── ChanNet command client ───────────────────────────────────────────────── + +/// POST a typed ChanCommand to ChanNet's /chan/command endpoint. +/// Returns the raw ZIP bytes from the response body. +pub async fn send_chan_command( + channet_url: &str, + command: &ChanCommand, +) -> Result { + let resp = reqwest::Client::new() + .post(format!("{channet_url}/chan/command")) + .json(command) + .send() + .await + .map_err(|e| ApiError::BroadcasterUnavailable( + format!("ChanNet unreachable at {channet_url}: {e}") + ))?; + + if !resp.status().is_success() { + return Err(ApiError::BroadcasterUnavailable( + format!("ChanNet /chan/command returned HTTP {}", resp.status()), + )); + } + + resp.bytes() + .await + .map_err(|e| ApiError::Internal(format!("reading ChanNet response body: {e}"))) +} + +// ── POST /chan/request ───────────────────────────────────────────────────── + +pub async fn chan_request( + State(state): State, + Json(command): Json, +) -> Result, ApiError> { + info!(?command, channet_url = %state.channet_url, "POST /chan/request"); + + // 1. Fetch ZIP from ChanNet. + let zip_bytes = send_chan_command(&state.channet_url, &command).await?; + let zip_len = zip_bytes.len(); + + // 2. AFSK-encode the ZIP into WAV bytes (CPU-bound; run on blocking thread). + let wav_bytes: Vec = tokio::task::spawn_blocking(move || { + let framed = framer::frame(&zip_bytes, "channet_payload.zip"); + let samples = encoder::encode(&framed); + wav::write_to_bytes(&samples) + }) + .await + .map_err(|e| ApiError::Internal(format!("task panic: {e}")))? + .map_err(ApiError::EncodeFailed)?; + + // 3. Forward the WAV to the external Broadcaster for over-air transmission. + // Reuses the same helper as /broadcast/transmit. + let tx_id = Uuid::new_v4(); + crate::api::broadcast::forward_to_broadcaster( + &state.broadcaster_url, + "channet_payload.zip", + wav_bytes, + tx_id, + ) + .await?; + + info!(%tx_id, zip_bytes = zip_len, "POST /chan/request transmitted successfully"); + + Ok(Json(ChanRequestResponse { + status: "transmitted", + tx_id, + zip_bytes: zip_len, + })) +} diff --git a/src/api/errors.rs b/src/api/errors.rs new file mode 100644 index 0000000..2d16687 --- /dev/null +++ b/src/api/errors.rs @@ -0,0 +1,76 @@ +//! API error type for RustWave. +//! +//! Every handler returns `Result<_, ApiError>`. axum automatically calls +//! `IntoResponse` on the error path. + +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use crate::api::models::{ErrorDetail, ErrorEnvelope}; + +#[derive(Debug)] +pub enum ApiError { + BadRequest(String), + PayloadTooLarge, + EncodeFailed(String), + DecodeFailed(String), + BroadcasterUnavailable(String), + Internal(String), +} + +impl ApiError { + fn code(&self) -> &'static str { + match self { + Self::BadRequest(_) => "BAD_REQUEST", + Self::PayloadTooLarge => "PAYLOAD_TOO_LARGE", + Self::EncodeFailed(_) => "ENCODE_FAILED", + Self::DecodeFailed(_) => "DECODE_FAILED", + Self::BroadcasterUnavailable(_) => "BROADCASTER_UNAVAILABLE", + Self::Internal(_) => "INTERNAL_ERROR", + } + } + + fn status_code(&self) -> StatusCode { + match self { + Self::BadRequest(_) => StatusCode::BAD_REQUEST, + Self::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE, + Self::EncodeFailed(_) => StatusCode::UNPROCESSABLE_ENTITY, + Self::DecodeFailed(_) => StatusCode::UNPROCESSABLE_ENTITY, + Self::BroadcasterUnavailable(_) => StatusCode::BAD_GATEWAY, + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let status = self.status_code(); + let message = match &self { + Self::BadRequest(m) => m.clone(), + Self::PayloadTooLarge => "Request body exceeds the 10 MB limit.".into(), + Self::EncodeFailed(m) => m.clone(), + Self::DecodeFailed(m) => m.clone(), + Self::BroadcasterUnavailable(m) => m.clone(), + Self::Internal(m) => m.clone(), + }; + + tracing::error!( + code = self.code(), + http_status = status.as_u16(), + %message, + "api error" + ); + + let body = ErrorEnvelope { + error: ErrorDetail { + code: self.code().into(), + message, + status: status.as_u16(), + }, + }; + + (status, Json(body)).into_response() + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..b56ecff --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,66 @@ +//! RustWave HTTP API server. +//! +//! full_router() — /wave/* + /broadcast/* + /chan/* (serve subcommand) +//! gui_router() — /broadcast/* + /chan/* (gui subcommand) + +pub mod broadcast; +pub mod chan; +pub mod errors; +pub mod models; +pub mod state; +pub mod wave; + +use axum::{routing::get, routing::post, Router}; +use std::net::SocketAddr; +use tower_http::limit::RequestBodyLimitLayer; +use tracing::info; + +use state::AppState; + +const BODY_LIMIT: usize = 10 * 1024 * 1024; // 10 MB + +pub fn full_router(state: AppState) -> Router { + let wave_routes = Router::new() + .route("/wave/status", get(wave::wave_status)) + .route("/wave/encode", post(wave::wave_encode)) + .route("/wave/decode", post(wave::wave_decode)); + + Router::new() + .merge(wave_routes) + .merge(broadcast_routes()) + .merge(chan_routes()) + .layer(RequestBodyLimitLayer::new(BODY_LIMIT)) + .with_state(state) +} + +pub fn gui_router(state: AppState) -> Router { + Router::new() + .merge(broadcast_routes()) + .merge(chan_routes()) + .layer(RequestBodyLimitLayer::new(BODY_LIMIT)) + .with_state(state) +} + +fn broadcast_routes() -> Router { + Router::new() + .route("/broadcast/status", get(broadcast::broadcast_status)) + .route("/broadcast/transmit", post(broadcast::broadcast_transmit)) + .route("/broadcast/receive", post(broadcast::broadcast_receive)) + .route("/broadcast/incoming", get(broadcast::broadcast_incoming)) +} + +fn chan_routes() -> Router { + Router::new() + .route("/chan/request", post(chan::chan_request)) +} + +pub async fn run_server(router: Router, bind_addr: SocketAddr) -> anyhow::Result<()> { + let listener = tokio::net::TcpListener::bind(bind_addr).await?; + info!(addr = %bind_addr, "RustWave API server listening"); + axum::serve(listener, router).await?; + Ok(()) +} + +#[cfg(test)] +pub mod tests; + diff --git a/src/api/models.rs b/src/api/models.rs new file mode 100644 index 0000000..43e2504 --- /dev/null +++ b/src/api/models.rs @@ -0,0 +1,96 @@ +//! JSON request and response types for the RustWave API. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// ── /wave/* responses ────────────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct WaveStatusResponse { + pub service: &'static str, + pub codec: &'static str, + pub version: &'static str, +} + +// ── /broadcast/* responses ───────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct BroadcastStatusResponse { + pub service: &'static str, + pub broadcaster_connected: bool, + pub channet_connected: bool, + pub broadcaster_url: String, + pub queue_depth: usize, +} + +#[derive(Serialize)] +pub struct TransmitResponse { + pub status: &'static str, + pub tx_id: Uuid, + pub wav_bytes: usize, +} + +#[derive(Serialize)] +pub struct ReceiveResponse { + pub status: &'static str, + pub queued_id: Uuid, + pub decoded_bytes: usize, +} + +/// Returned by GET /broadcast/incoming when the queue is empty. +#[derive(Serialize, Deserialize)] +pub struct QueueEmptyResponse { + pub status: &'static str, +} + +// ── ChanNet /chan/command request types ──────────────────────────────────── +// +// Mirrors the six commands defined in the ChanNet API reference exactly. +// The `type` field is serialised as the serde tag so the JSON sent to +// /chan/command matches the format ChanNet expects. + +#[derive(Serialize, Deserialize, Debug)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ChanCommand { + /// All boards + all active (non-archived) posts. Optional delta via `since`. + FullExport { since: Option }, + /// All active posts on a single board. Optional delta via `since`. + BoardExport { board: String, since: Option }, + /// All posts in a single thread. Optional delta via `since`. + ThreadExport { thread_id: u64, since: Option }, + /// All archived threads + posts for a single board. Always a full export. + ArchiveExport { board: String }, + /// Entire database — all boards, threads, archives, posts. Use for initial + /// sync or recovery only; RustChan logs a warning when this is received. + ForceRefresh, + /// Post a new reply to an existing thread (the only write command). + ReplyPush { + board: String, + thread_id: u64, + author: String, + content: String, + timestamp: u64, + }, +} + +/// Returned by POST /chan/request on success. +#[derive(Serialize)] +pub struct ChanRequestResponse { + pub status: &'static str, // "transmitted" + pub tx_id: uuid::Uuid, + pub zip_bytes: usize, +} + +// ── Error envelope ───────────────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct ErrorDetail { + pub code: String, + pub message: String, + pub status: u16, +} + +#[derive(Serialize)] +pub struct ErrorEnvelope { + pub error: ErrorDetail, +} diff --git a/src/api/state.rs b/src/api/state.rs new file mode 100644 index 0000000..7a46a40 --- /dev/null +++ b/src/api/state.rs @@ -0,0 +1,51 @@ +//! Shared state for the RustWave API server. + +use std::{collections::VecDeque, sync::Arc}; +use bytes::Bytes; +use tokio::sync::Mutex; +use uuid::Uuid; + +#[derive(Debug)] +pub struct QueuedFile { + pub queued_id: Uuid, + pub bytes: Bytes, +} + +pub type IncomingQueue = Arc>>; + +#[derive(Clone)] +pub struct AppState { + pub broadcaster_url: String, + pub channet_url: String, + pub wave_routes_enabled: bool, + pub incoming_queue: IncomingQueue, +} + +impl AppState { + pub fn new(wave_routes_enabled: bool) -> Self { + let broadcaster_url = std::env::var("RUSTWAVE_BROADCASTER_URL") + .unwrap_or_else(|_| "http://localhost:9090".to_string()); + + let channet_url = std::env::var("RUSTWAVE_CHANNET_URL") + .unwrap_or_else(|_| "http://localhost:7070".to_string()); + + Self { + broadcaster_url, + channet_url, + wave_routes_enabled, + incoming_queue: Arc::new(Mutex::new(VecDeque::new())), + } + } + + pub async fn queue_depth(&self) -> usize { + self.incoming_queue.lock().await.len() + } + + pub async fn enqueue(&self, file: QueuedFile) { + self.incoming_queue.lock().await.push_back(file); + } + + pub async fn dequeue(&self) -> Option { + self.incoming_queue.lock().await.pop_front() + } +} diff --git a/src/api/wave.rs b/src/api/wave.rs new file mode 100644 index 0000000..bf7e55b --- /dev/null +++ b/src/api/wave.rs @@ -0,0 +1,122 @@ +//! Handlers for the /wave/* general-purpose codec endpoints. +//! Only registered in `serve` mode — NOT in GUI mode. + +use axum::{ + extract::Multipart, + http::header, + response::{IntoResponse, Response}, + Json, +}; +use tracing::{info, warn}; + +use crate::{ + api::{errors::ApiError, models::WaveStatusResponse}, + decoder, encoder, framer, wav, +}; + +// ── GET /wave/status ─────────────────────────────────────────────────────── + +pub async fn wave_status() -> Json { + info!("GET /wave/status"); + Json(WaveStatusResponse { + service: "rustwave", + codec: "afsk-1200", + version: env!("CARGO_PKG_VERSION"), + }) +} + +// ── POST /wave/encode ────────────────────────────────────────────────────── + +pub async fn wave_encode(mut multipart: Multipart) -> Result { + let (filename, file_bytes) = extract_file_field(&mut multipart).await?; + + info!(filename = %filename, input_bytes = file_bytes.len(), "POST /wave/encode starting"); + + let result = tokio::task::spawn_blocking(move || { + let framed = framer::frame(&file_bytes, &filename); + let samples = encoder::encode(&framed); + let wav_bytes = wav::write_to_bytes(&samples)?; + Ok::<(String, Vec), String>((filename, wav_bytes)) + }) + .await + .map_err(|e| ApiError::Internal(format!("task panic: {e}")))? + .map_err(ApiError::EncodeFailed)?; + + let (original_filename, wav_bytes) = result; + let stem = std::path::Path::new(&original_filename) + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + let out_name = format!("{stem}_encoded.wav"); + + info!(output_filename = %out_name, wav_bytes = wav_bytes.len(), "POST /wave/encode complete"); + + Ok(( + [ + (header::CONTENT_TYPE, "audio/wav"), + (header::CONTENT_DISPOSITION, &format!("attachment; filename=\"{out_name}\"")), + ], + wav_bytes, + ) + .into_response()) +} + +// ── POST /wave/decode ────────────────────────────────────────────────────── + +pub async fn wave_decode(mut multipart: Multipart) -> Result { + let (_field_name, wav_bytes) = extract_file_field(&mut multipart).await?; + + info!(wav_bytes = wav_bytes.len(), "POST /wave/decode starting"); + + let result = tokio::task::spawn_blocking(move || { + let samples = wav::read_from_bytes(&wav_bytes)?; + let decoded = decoder::decode(&samples)?; + Ok::(decoded) + }) + .await + .map_err(|e| ApiError::Internal(format!("task panic: {e}")))? + .map_err(ApiError::DecodeFailed)?; + + info!( + original_filename = %result.filename, + decoded_bytes = result.data.len(), + "POST /wave/decode complete" + ); + + Ok(( + [ + (header::CONTENT_TYPE, "application/octet-stream"), + (header::CONTENT_DISPOSITION, &format!("attachment; filename=\"{}\"", result.filename)), + ], + result.data, + ) + .into_response()) +} + +// ── Shared helper ────────────────────────────────────────────────────────── + +async fn extract_file_field( + multipart: &mut Multipart, +) -> Result<(String, Vec), ApiError> { + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| ApiError::BadRequest(format!("multipart error: {e}")))? + { + let filename = field.file_name().unwrap_or("upload").to_string(); + let data = field + .bytes() + .await + .map_err(|e| ApiError::BadRequest(format!("could not read field bytes: {e}")))?; + + if data.is_empty() { + warn!(filename = %filename, "received empty file field"); + return Err(ApiError::BadRequest("file field is empty".into())); + } + + return Ok((filename, data.to_vec())); + } + + Err(ApiError::BadRequest("no file field found in multipart body".into())) +} diff --git a/src/gui.rs b/src/gui.rs index bd3aac1..e5e25f1 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -451,6 +451,29 @@ fn dashed_border(painter: &egui::Painter, rect: Rect, stroke: Stroke) { // ─── Entry point ───────────────────────────────────────────────────────────── pub fn run() -> eframe::Result<()> { + // Spawn the /broadcast/* API server on a background OS thread. + std::thread::spawn(|| { + let addr: std::net::SocketAddr = std::env::var("RUSTWAVE_BIND") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or_else(|| "127.0.0.1:7071".parse().unwrap()); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to build Tokio runtime for GUI API server"); + + rt.block_on(async move { + let state = crate::api::state::AppState::new(false); + let router = crate::api::gui_router(state); + if let Err(e) = crate::api::run_server(router, addr).await { + tracing::error!("GUI API server error: {e}"); + } + }); + }); + + tracing::info!("GUI mode: /broadcast/* API started on 127.0.0.1:7071"); + let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([480.0, 340.0]) diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..9536373 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,53 @@ +//! Logging initialisation for RustWave. +//! +//! Call `logging::init()` once at the start of `main()`. +//! +//! Log output: +//! - stderr: INFO and above, human-readable +//! - rustwave.log (file): DEBUG and above, JSON format, rolling daily +//! +//! The log file is written next to the binary. +//! Set RUSTWAVE_LOG=debug to see debug output on stderr too. + +use std::path::PathBuf; +use tracing_appender::rolling; +use tracing_subscriber::{ + fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer, +}; + +/// Initialise logging. Must be called once before any tracing macros are used. +/// +/// Returns the `_guard` from `tracing_appender::non_blocking`. The caller MUST +/// hold this value for the lifetime of the process; dropping it flushes and +/// closes the log file. +pub fn init() -> tracing_appender::non_blocking::WorkerGuard { + let log_dir: PathBuf = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(PathBuf::from)) + .unwrap_or_else(|| PathBuf::from(".")); + + // Rolling daily log file: rustwave.YYYY-MM-DD + let file_appender = rolling::daily(&log_dir, "rustwave.log"); + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + + // stderr layer — human readable, INFO+ by default, respects RUSTWAVE_LOG + let stderr_filter = EnvFilter::try_from_env("RUSTWAVE_LOG") + .unwrap_or_else(|_| EnvFilter::new("info")); + + let stderr_layer = fmt::layer() + .with_target(false) + .with_filter(stderr_filter); + + // file layer — JSON, DEBUG+ + let file_layer = fmt::layer() + .json() + .with_writer(non_blocking) + .with_filter(EnvFilter::new("debug")); + + tracing_subscriber::registry() + .with(stderr_layer) + .with(file_layer) + .init(); + + guard +} diff --git a/src/main.rs b/src/main.rs index 3483e84..ee1796f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,14 @@ +mod api; mod config; mod decoder; mod encoder; mod framer; mod gui; +mod logging; mod wav; use clap::{Parser, Subcommand}; -use std::path::PathBuf; +use std::{net::SocketAddr, path::PathBuf}; #[derive(Parser)] #[command(name = "rustwave-cli", version, about = "RustWave audio codec", long_about = None)] @@ -17,27 +19,31 @@ struct Cli { #[derive(Subcommand)] enum Command { - /// Launch the drag-and-drop GUI + /// Launch the drag-and-drop GUI (also starts /broadcast/* API on 127.0.0.1:7071) Gui, - /// Encode a file into an AFSK WAV (original filename is stored in the signal) + /// Start the HTTP API server (both /wave/* and /broadcast/* on 127.0.0.1:7071) + Serve { + #[arg(short, long, value_name = "ADDR")] + bind: Option, + }, + /// Encode a file into an AFSK WAV Encode { #[arg(short, long, value_name = "FILE")] input: PathBuf, #[arg(short, long, value_name = "FILE")] output: PathBuf, }, - /// Decode an AFSK WAV — restores the original filename automatically. - /// If -o is omitted the file is written next to the WAV with its original name. + /// Decode an AFSK WAV — restores the original filename automatically Decode { #[arg(short, long, value_name = "FILE")] input: PathBuf, - /// Output path (optional — defaults to original filename next to the WAV) #[arg(short, long, value_name = "FILE")] output: Option, }, } fn main() { + let _log_guard = logging::init(); if let Err(e) = run() { eprintln!("error: {e}"); std::process::exit(1); @@ -52,56 +58,48 @@ fn run() -> Result<(), String> { gui::run().map_err(|e| format!("GUI error: {e}"))?; } + Command::Serve { bind } => { + let addr = bind + .or_else(|| std::env::var("RUSTWAVE_BIND").ok().and_then(|s| s.parse().ok())) + .unwrap_or_else(|| "127.0.0.1:7071".parse().unwrap()); + + let rt = tokio::runtime::Runtime::new() + .map_err(|e| format!("failed to build Tokio runtime: {e}"))?; + + rt.block_on(async move { + let state = api::state::AppState::new(true); + let router = api::full_router(state); + api::run_server(router, addr) + .await + .map_err(|e| format!("server error: {e}")) + })?; + } + Command::Encode { input, output } => { let data = std::fs::read(&input) .map_err(|e| format!("cannot read '{}': {e}", input.display()))?; - - let filename = input - .file_name() - .unwrap_or_default() - .to_string_lossy() - .into_owned(); - + let filename = input.file_name().unwrap_or_default().to_string_lossy().into_owned(); let framed = framer::frame(&data, &filename); let samples = encoder::encode(&framed); - wav::write(&output, &samples) .map_err(|e| format!("cannot write '{}': {e}", output.display()))?; - #[allow(clippy::cast_precision_loss)] let duration = samples.len() as f64 / f64::from(config::SAMPLE_RATE); - eprintln!( - "encoded '{}' ({} byte{}) -> {} ({duration:.2} s)", - filename, - data.len(), - plural(data.len()), - output.display(), - ); + eprintln!("encoded '{}' ({} byte{}) -> {} ({duration:.2} s)", + filename, data.len(), plural(data.len()), output.display()); } Command::Decode { input, output } => { - let samples = - wav::read(&input).map_err(|e| format!("cannot read '{}': {e}", input.display()))?; - + let samples = wav::read(&input) + .map_err(|e| format!("cannot read '{}': {e}", input.display()))?; let decoded = decoder::decode(&samples).map_err(|e| format!("decode failed: {e}"))?; - let out_path = output.unwrap_or_else(|| { - input - .parent() - .unwrap_or_else(|| std::path::Path::new(".")) - .join(&decoded.filename) + input.parent().unwrap_or_else(|| std::path::Path::new(".")).join(&decoded.filename) }); - std::fs::write(&out_path, &decoded.data) .map_err(|e| format!("cannot write '{}': {e}", out_path.display()))?; - - eprintln!( - "decoded {} byte{} -> '{}' (original filename: '{}')", - decoded.data.len(), - plural(decoded.data.len()), - out_path.display(), - decoded.filename, - ); + eprintln!("decoded {} byte{} -> '{}' (original filename: '{}')", + decoded.data.len(), plural(decoded.data.len()), out_path.display(), decoded.filename); } } @@ -109,9 +107,5 @@ fn run() -> Result<(), String> { } const fn plural(n: usize) -> &'static str { - if n == 1 { - "" - } else { - "s" - } + if n == 1 { "" } else { "s" } } diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 0000000..bfb7b5e --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,24 @@ +#[cfg(test)] +mod tests { + use crate::api::state::AppState; + + #[tokio::test] + async fn queue_enqueue_dequeue() { + use crate::api::state::QueuedFile; + use bytes::Bytes; + use uuid::Uuid; + + let state = AppState::new(false); + assert_eq!(state.queue_depth().await, 0); + + state.enqueue(QueuedFile { + queued_id: Uuid::new_v4(), + bytes: Bytes::from_static(b"hello"), + }).await; + + assert_eq!(state.queue_depth().await, 1); + let file = state.dequeue().await.unwrap(); + assert_eq!(file.bytes.as_ref(), b"hello"); + assert!(state.dequeue().await.is_none()); + } +} diff --git a/src/wav.rs b/src/wav.rs index 67a6d2a..bfb31e0 100644 --- a/src/wav.rs +++ b/src/wav.rs @@ -53,6 +53,55 @@ pub fn read(path: &Path) -> Result, String> { } } +// ── In-memory variants used by the HTTP API ────────────────────────────── + +pub fn write_to_bytes(samples: &[f64]) -> Result, String> { + use std::io::Cursor; + let spec = hound::WavSpec { + channels: 1, + sample_rate: SAMPLE_RATE, + bits_per_sample: 16, + sample_format: hound::SampleFormat::Int, + }; + + let mut buf: Vec = Vec::new(); + let cursor = Cursor::new(&mut buf); + let mut writer = hound::WavWriter::new(cursor, spec).map_err(|e| e.to_string())?; + + for &s in samples { + #[allow(clippy::cast_possible_truncation)] + let v = (s.clamp(-1.0, 1.0) * 32_767.0) as i16; + writer.write_sample(v).map_err(|e| e.to_string())?; + } + + writer.finalize().map_err(|e| e.to_string())?; + Ok(buf) +} + +pub fn read_from_bytes(data: &[u8]) -> Result, String> { + use std::io::Cursor; + let cursor = Cursor::new(data); + let mut reader = hound::WavReader::new(cursor).map_err(|e| e.to_string())?; + let spec = reader.spec(); + + match (spec.bits_per_sample, spec.sample_format) { + (16, hound::SampleFormat::Int) => { + let channels = usize::from(spec.channels); + if channels == 0 { + return Err("invalid WAV: 0 channels".into()); + } + reader + .samples::() + .step_by(channels) + .map(|s| s.map(|v| f64::from(v) / 32_768.0).map_err(|e| e.to_string())) + .collect() + } + (bits, fmt) => Err(format!( + "unsupported WAV format: {bits}-bit {fmt:?} (rustwave-cli expects 16-bit integer PCM)" + )), + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -112,4 +161,20 @@ mod tests { } Ok(()) } + + #[test] + fn memory_round_trip() -> Result<(), String> { + use std::f64::consts::TAU; + #[allow(clippy::cast_precision_loss)] + let original: Vec = (0..4_410_i32) + .map(|i| 0.5 * (TAU * 440.0 * f64::from(i) / 44_100.0).sin()) + .collect(); + let bytes = write_to_bytes(&original)?; + let recovered = read_from_bytes(&bytes)?; + assert_eq!(original.len(), recovered.len()); + for (a, b) in original.iter().zip(recovered.iter()) { + assert!((a - b).abs() < 5e-5, "quantisation error: {a} vs {b}"); + } + Ok(()) + } } From f10131e736e75b1b9da2ba38e7200a704e40d4c2 Mon Sep 17 00:00:00 2001 From: csd113 Date: Tue, 17 Mar 2026 11:19:23 -0700 Subject: [PATCH 2/3] Create RustWave_API_Reference.docx --- RustWave_API_Reference.docx | Bin 0 -> 25431 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 RustWave_API_Reference.docx diff --git a/RustWave_API_Reference.docx b/RustWave_API_Reference.docx new file mode 100644 index 0000000000000000000000000000000000000000..23fa67ad93b1d5742d16f89c8609c4ae90d16447 GIT binary patch literal 25431 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 Tf9!t%6aWeu@`_*Q>W}{yC9V@( literal 0 HcmV?d00001 From b2b0c6a258654a50183f56412bc11dbcc6c9a6a5 Mon Sep 17 00:00:00 2001 From: csd113 Date: Tue, 17 Mar 2026 11:55:58 -0700 Subject: [PATCH 3/3] fixed clippy issues no other changes --- Cargo.lock | 24 +- .../RustWave-tree-annotated.txt | 0 .../RustWave_API_Reference.docx | Bin rustwave-api/rustwave_api_build_plan.md | 1318 --------------- .../rustwave_api_staged_build_prompt.md | 1462 ----------------- src/api/broadcast.rs | 114 +- src/api/chan.rs | 36 +- src/api/errors.rs | 37 +- src/api/mod.rs | 14 +- src/api/models.rs | 14 +- src/api/state.rs | 5 +- src/api/tests.rs | 50 + src/api/wave.rs | 46 +- src/gui.rs | 12 +- src/logging.rs | 16 +- src/main.rs | 47 +- src/wav.rs | 5 +- 17 files changed, 257 insertions(+), 2943 deletions(-) rename {rustwave-api => docs}/RustWave-tree-annotated.txt (100%) rename RustWave_API_Reference.docx => docs/RustWave_API_Reference.docx (100%) delete mode 100644 rustwave-api/rustwave_api_build_plan.md delete mode 100644 rustwave-api/rustwave_api_staged_build_prompt.md create mode 100644 src/api/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 62917f8..626936d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -210,7 +210,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -221,7 +221,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1206,7 +1206,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2395,7 +2395,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3228,7 +3228,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3605,7 +3605,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3734,10 +3734,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4158,13 +4158,13 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "uds_windows" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4695,7 +4695,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/rustwave-api/RustWave-tree-annotated.txt b/docs/RustWave-tree-annotated.txt similarity index 100% rename from rustwave-api/RustWave-tree-annotated.txt rename to docs/RustWave-tree-annotated.txt diff --git a/RustWave_API_Reference.docx b/docs/RustWave_API_Reference.docx similarity index 100% rename from RustWave_API_Reference.docx rename to docs/RustWave_API_Reference.docx diff --git a/rustwave-api/rustwave_api_build_plan.md b/rustwave-api/rustwave_api_build_plan.md deleted file mode 100644 index d4fc1b8..0000000 --- a/rustwave-api/rustwave_api_build_plan.md +++ /dev/null @@ -1,1318 +0,0 @@ -# RustWave API — Build Plan -*Derived from `rustwave_api_implementation_plan.md` · March 2026* - ---- - -## Files to Edit or Create — Summary - -| File | Action | Why | -|---|---|---| -| `Cargo.toml` | **EDIT** | Add tokio, axum, tower, tower-http, reqwest, serde, uuid, tracing, bytes | -| `src/main.rs` | **EDIT** | Add `serve` subcommand, logging init, `mod api`, `mod logging` | -| `src/gui.rs` | **EDIT** | Spawn broadcast API thread before launching eframe | -| `src/wav.rs` | **EDIT** | Add `write_to_bytes` and `read_from_bytes` + new unit test | -| `src/logging.rs` | **CREATE** | Logging initialisation (stderr + rolling JSON file) | -| `src/api/mod.rs` | **CREATE** | Router builder: `full_router`, `gui_router`, `run_server` | -| `src/api/models.rs` | **CREATE** | All JSON request/response structs | -| `src/api/errors.rs` | **CREATE** | `ApiError` enum with `IntoResponse` impl | -| `src/api/state.rs` | **CREATE** | `AppState`, `IncomingQueue`, `QueuedFile` | -| `src/api/wave.rs` | **CREATE** | Handlers: `GET /wave/status`, `POST /wave/encode`, `POST /wave/decode` | -| `src/api/broadcast.rs` | **CREATE** | Handlers: `GET /broadcast/status`, `POST /broadcast/transmit`, `POST /broadcast/receive`, `GET /broadcast/incoming`; `pub forward_to_broadcaster()` shared with `chan.rs` | -| `src/api/chan.rs` | **CREATE** | ChanNet HTTP client (`check_channet_reachable`, `send_chan_command`) and `POST /chan/request` proxy handler — fetches ZIP from ChanNet, AFSK-encodes it, forwards WAV to Broadcaster for over-the-air transmission | -| `src/api/tests.rs` | **CREATE** | Unit test: queue enqueue/dequeue round-trip | - -**Unchanged:** `src/config.rs`, `src/encoder.rs`, `src/decoder.rs`, `src/framer.rs`, `deny.toml`, `Cargo.lock` (auto-updated). - ---- - -## Step-by-Step Build Plan - -Each step ends with `cargo check`. Do **not** advance until it is green. Commit after each step. - ---- - -### Step 1 — `Cargo.toml` - -Replace the `[dependencies]` block: - -```toml -[package] -name = "rustwave" -version = "0.1.0" -edition = "2021" -license = "MIT" -description = "RustWave audio codec — encode bytes to WAV, decode WAV to bytes" - -[[bin]] -name = "rustwave-cli" -path = "src/main.rs" - -[dependencies] -# — existing — -clap = { version = "4", features = ["derive"] } -hound = "3" -eframe = "0.31" - -# — NEW: async runtime (required for axum) — -tokio = { version = "1", features = ["full"] } - -# — NEW: HTTP server — -axum = { version = "0.7", features = ["multipart"] } -tower = "0.4" -tower-http = { version = "0.5", features = ["cors", "limit"] } - -# — NEW: HTTP client (forward WAV to Broadcaster) — -reqwest = { version = "0.12", features = ["multipart"] } - -# — NEW: serialisation — -serde = { version = "1", features = ["derive"] } -serde_json = "1" - -# — NEW: UUID generation for tx_id / queued_id — -uuid = { version = "1", features = ["v4"] } - -# — NEW: logging / tracing — -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } -tracing-appender = "0.2" - -# — NEW: in-memory byte buffers (WAV bytes without temp files) — -bytes = "1" -``` - -```bash -cargo check # must be green before continuing -``` - -**Commit:** `chore: add API dependencies to Cargo.toml` - ---- - -### Step 2 — `src/logging.rs` *(CREATE)* - -```rust -//! Logging initialisation for RustWave. -//! -//! Call `logging::init()` once at the start of `main()`. -//! -//! Log output: -//! - stderr: INFO and above, human-readable -//! - rustwave.log (file): DEBUG and above, JSON format, rolling daily -//! -//! The log file is written next to the binary. -//! Set RUSTWAVE_LOG=debug to see debug output on stderr too. - -use std::path::PathBuf; -use tracing_appender::rolling; -use tracing_subscriber::{ - fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, -}; - -/// Initialise logging. Must be called once before any tracing macros are used. -/// -/// Returns the `_guard` from `tracing_appender::non_blocking`. The caller MUST -/// hold this value for the lifetime of the process; dropping it flushes and -/// closes the log file. -pub fn init() -> tracing_appender::non_blocking::WorkerGuard { - let log_dir: PathBuf = std::env::current_exe() - .ok() - .and_then(|p| p.parent().map(PathBuf::from)) - .unwrap_or_else(|| PathBuf::from(".")); - - // Rolling daily log file: rustwave.YYYY-MM-DD - let file_appender = rolling::daily(&log_dir, "rustwave.log"); - let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); - - // stderr layer — human readable, INFO+ by default, respects RUSTWAVE_LOG - let stderr_filter = EnvFilter::try_from_env("RUSTWAVE_LOG") - .unwrap_or_else(|_| EnvFilter::new("info")); - - let stderr_layer = fmt::layer() - .with_target(false) - .with_filter(stderr_filter); - - // file layer — JSON, DEBUG+ - let file_layer = fmt::layer() - .json() - .with_writer(non_blocking) - .with_filter(EnvFilter::new("debug")); - - tracing_subscriber::registry() - .with(stderr_layer) - .with(file_layer) - .init(); - - guard -} -``` - -```bash -cargo check -``` - -**Commit:** `feat: add logging module` - ---- - -### Step 3 — `src/api/models.rs` *(CREATE)* - -```bash -mkdir src/api -``` - -```rust -//! JSON request and response types for the RustWave API. - -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -// ── /wave/* responses ────────────────────────────────────────────────────── - -#[derive(Serialize)] -pub struct WaveStatusResponse { - pub service: &'static str, - pub codec: &'static str, - pub version: &'static str, -} - -// ── /broadcast/* responses ───────────────────────────────────────────────── - -#[derive(Serialize)] -pub struct BroadcastStatusResponse { - pub service: &'static str, - pub broadcaster_connected: bool, - pub channet_connected: bool, - pub broadcaster_url: String, - pub queue_depth: usize, -} - -#[derive(Serialize)] -pub struct TransmitResponse { - pub status: &'static str, - pub tx_id: Uuid, - pub wav_bytes: usize, -} - -#[derive(Serialize)] -pub struct ReceiveResponse { - pub status: &'static str, - pub queued_id: Uuid, - pub decoded_bytes: usize, -} - -/// Returned by GET /broadcast/incoming when the queue is empty. -#[derive(Serialize, Deserialize)] -pub struct QueueEmptyResponse { - pub status: &'static str, -} - -// ── ChanNet /chan/command request types ──────────────────────────────────── -// -// Mirrors the six commands defined in the ChanNet API reference exactly. -// The `type` field is serialised as the serde tag so the JSON sent to -// /chan/command matches the format ChanNet expects. - -#[derive(Serialize, Deserialize, Debug)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ChanCommand { - /// All boards + all active (non-archived) posts. Optional delta via `since`. - FullExport { since: Option }, - /// All active posts on a single board. Optional delta via `since`. - BoardExport { board: String, since: Option }, - /// All posts in a single thread. Optional delta via `since`. - ThreadExport { thread_id: u64, since: Option }, - /// All archived threads + posts for a single board. Always a full export. - ArchiveExport { board: String }, - /// Entire database — all boards, threads, archives, posts. Use for initial - /// sync or recovery only; RustChan logs a warning when this is received. - ForceRefresh, - /// Post a new reply to an existing thread (the only write command). - ReplyPush { - board: String, - thread_id: u64, - author: String, - content: String, - timestamp: u64, - }, -} - -/// Returned by POST /chan/request on success. -#[derive(Serialize)] -pub struct ChanRequestResponse { - pub status: &'static str, // "transmitted" - pub tx_id: uuid::Uuid, - pub zip_bytes: usize, -} - -// ── Error envelope (Section 2.4) ─────────────────────────────────────────── - -#[derive(Serialize)] -pub struct ErrorDetail { - pub code: String, - pub message: String, - pub status: u16, -} - -#[derive(Serialize)] -pub struct ErrorEnvelope { - pub error: ErrorDetail, -} -``` - -```bash -cargo check -``` - -**Commit:** `feat: add api/models.rs` - ---- - -### Step 4 — `src/api/errors.rs` *(CREATE)* - -```rust -//! API error type for RustWave. -//! -//! Every handler returns `Result<_, ApiError>`. axum automatically calls -//! `IntoResponse` on the error path. - -use axum::{ - http::StatusCode, - response::{IntoResponse, Response}, - Json, -}; -use crate::api::models::{ErrorDetail, ErrorEnvelope}; - -#[derive(Debug)] -pub enum ApiError { - BadRequest(String), - PayloadTooLarge, - EncodeFailed(String), - DecodeFailed(String), - BroadcasterUnavailable(String), - Internal(String), -} - -impl ApiError { - fn code(&self) -> &'static str { - match self { - Self::BadRequest(_) => "BAD_REQUEST", - Self::PayloadTooLarge => "PAYLOAD_TOO_LARGE", - Self::EncodeFailed(_) => "ENCODE_FAILED", - Self::DecodeFailed(_) => "DECODE_FAILED", - Self::BroadcasterUnavailable(_) => "BROADCASTER_UNAVAILABLE", - Self::Internal(_) => "INTERNAL_ERROR", - } - } - - fn status_code(&self) -> StatusCode { - match self { - Self::BadRequest(_) => StatusCode::BAD_REQUEST, - Self::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE, - Self::EncodeFailed(_) => StatusCode::UNPROCESSABLE_ENTITY, - Self::DecodeFailed(_) => StatusCode::UNPROCESSABLE_ENTITY, - Self::BroadcasterUnavailable(_) => StatusCode::BAD_GATEWAY, - Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, - } - } -} - -impl IntoResponse for ApiError { - fn into_response(self) -> Response { - let status = self.status_code(); - let message = match &self { - Self::BadRequest(m) => m.clone(), - Self::PayloadTooLarge => "Request body exceeds the 10 MB limit.".into(), - Self::EncodeFailed(m) => m.clone(), - Self::DecodeFailed(m) => m.clone(), - Self::BroadcasterUnavailable(m) => m.clone(), - Self::Internal(m) => m.clone(), - }; - - tracing::error!( - code = self.code(), - http_status = status.as_u16(), - %message, - "api error" - ); - - let body = ErrorEnvelope { - error: ErrorDetail { - code: self.code().into(), - message, - status: status.as_u16(), - }, - }; - - (status, Json(body)).into_response() - } -} -``` - -```bash -cargo check -``` - -**Commit:** `feat: add api/errors.rs` - ---- - -### Step 5 — `src/api/state.rs` *(CREATE)* - -```rust -//! Shared state for the RustWave API server. - -use std::{collections::VecDeque, sync::Arc}; -use bytes::Bytes; -use tokio::sync::Mutex; -use uuid::Uuid; - -#[derive(Debug)] -pub struct QueuedFile { - pub queued_id: Uuid, - pub bytes: Bytes, -} - -pub type IncomingQueue = Arc>>; - -#[derive(Clone)] -pub struct AppState { - pub broadcaster_url: String, - pub channet_url: String, - pub wave_routes_enabled: bool, - pub incoming_queue: IncomingQueue, -} - -impl AppState { - pub fn new(wave_routes_enabled: bool) -> Self { - let broadcaster_url = std::env::var("RUSTWAVE_BROADCASTER_URL") - .unwrap_or_else(|_| "http://localhost:9090".to_string()); - - let channet_url = std::env::var("RUSTWAVE_CHANNET_URL") - .unwrap_or_else(|_| "http://localhost:7070".to_string()); - - Self { - broadcaster_url, - channet_url, - wave_routes_enabled, - incoming_queue: Arc::new(Mutex::new(VecDeque::new())), - } - } - - pub async fn queue_depth(&self) -> usize { - self.incoming_queue.lock().await.len() - } - - pub async fn enqueue(&self, file: QueuedFile) { - self.incoming_queue.lock().await.push_back(file); - } - - pub async fn dequeue(&self) -> Option { - self.incoming_queue.lock().await.pop_front() - } -} -``` - -```bash -cargo check -``` - -**Commit:** `feat: add api/state.rs` - ---- - -### Step 6 — `src/api/wave.rs` *(CREATE)* - -```rust -//! Handlers for the /wave/* general-purpose codec endpoints. -//! Only registered in `serve` mode — NOT in GUI mode. - -use axum::{ - extract::Multipart, - http::header, - response::{IntoResponse, Response}, - Json, -}; -use tracing::{info, warn}; - -use crate::{ - api::{errors::ApiError, models::WaveStatusResponse}, - decoder, encoder, framer, wav, -}; - -// ── GET /wave/status ─────────────────────────────────────────────────────── - -pub async fn wave_status() -> Json { - info!("GET /wave/status"); - Json(WaveStatusResponse { - service: "rustwave", - codec: "afsk-1200", - version: env!("CARGO_PKG_VERSION"), - }) -} - -// ── POST /wave/encode ────────────────────────────────────────────────────── - -pub async fn wave_encode(mut multipart: Multipart) -> Result { - let (filename, file_bytes) = extract_file_field(&mut multipart).await?; - - info!(filename = %filename, input_bytes = file_bytes.len(), "POST /wave/encode starting"); - - let result = tokio::task::spawn_blocking(move || { - let framed = framer::frame(&file_bytes, &filename); - let samples = encoder::encode(&framed); - let wav_bytes = wav::write_to_bytes(&samples)?; - Ok::<(String, Vec), String>((filename, wav_bytes)) - }) - .await - .map_err(|e| ApiError::Internal(format!("task panic: {e}")))? - .map_err(ApiError::EncodeFailed)?; - - let (original_filename, wav_bytes) = result; - let stem = std::path::Path::new(&original_filename) - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .into_owned(); - let out_name = format!("{stem}_encoded.wav"); - - info!(output_filename = %out_name, wav_bytes = wav_bytes.len(), "POST /wave/encode complete"); - - Ok(( - [ - (header::CONTENT_TYPE, "audio/wav"), - (header::CONTENT_DISPOSITION, &format!("attachment; filename=\"{out_name}\"")), - ], - wav_bytes, - ) - .into_response()) -} - -// ── POST /wave/decode ────────────────────────────────────────────────────── - -pub async fn wave_decode(mut multipart: Multipart) -> Result { - let (_field_name, wav_bytes) = extract_file_field(&mut multipart).await?; - - info!(wav_bytes = wav_bytes.len(), "POST /wave/decode starting"); - - let result = tokio::task::spawn_blocking(move || { - let samples = wav::read_from_bytes(&wav_bytes)?; - let decoded = decoder::decode(&samples)?; - Ok::(decoded) - }) - .await - .map_err(|e| ApiError::Internal(format!("task panic: {e}")))? - .map_err(ApiError::DecodeFailed)?; - - info!( - original_filename = %result.filename, - decoded_bytes = result.data.len(), - "POST /wave/decode complete" - ); - - Ok(( - [ - (header::CONTENT_TYPE, "application/octet-stream"), - (header::CONTENT_DISPOSITION, &format!("attachment; filename=\"{}\"", result.filename)), - ], - result.data, - ) - .into_response()) -} - -// ── Shared helper ────────────────────────────────────────────────────────── - -async fn extract_file_field( - multipart: &mut Multipart, -) -> Result<(String, Vec), ApiError> { - while let Some(field) = multipart - .next_field() - .await - .map_err(|e| ApiError::BadRequest(format!("multipart error: {e}")))? - { - let filename = field.file_name().unwrap_or("upload").to_string(); - let data = field - .bytes() - .await - .map_err(|e| ApiError::BadRequest(format!("could not read field bytes: {e}")))?; - - if data.is_empty() { - warn!(filename = %filename, "received empty file field"); - return Err(ApiError::BadRequest("file field is empty".into())); - } - - return Ok((filename, data.to_vec())); - } - - Err(ApiError::BadRequest("no file field found in multipart body".into())) -} -``` - -```bash -cargo check -``` - -**Commit:** `feat: add api/wave.rs handlers` - ---- - -### Step 7 — `src/api/broadcast.rs` *(CREATE)* - -```rust -//! Handlers for the /broadcast/* channel network endpoints. -//! Exposed in both `serve` mode and `gui` mode. - -use axum::{ - extract::{Multipart, State}, - http::{header, StatusCode}, - response::{IntoResponse, Response}, - Json, -}; -use bytes::Bytes; -use tracing::{debug, info, warn}; -use uuid::Uuid; - -use crate::{ - api::{ - errors::ApiError, - models::{BroadcastStatusResponse, QueueEmptyResponse, ReceiveResponse, TransmitResponse}, - state::{AppState, QueuedFile}, - }, - decoder, encoder, framer, wav, -}; - -// ── GET /broadcast/status ────────────────────────────────────────────────── - -pub async fn broadcast_status(State(state): State) -> Json { - let queue_depth = state.queue_depth().await; - let broadcaster_connected = check_broadcaster_reachable(&state.broadcaster_url).await; - let channet_connected = crate::api::chan::check_channet_reachable(&state.channet_url).await; - - info!( - queue_depth, - broadcaster_connected, - channet_connected, - broadcaster_url = %state.broadcaster_url, - channet_url = %state.channet_url, - "GET /broadcast/status" - ); - - Json(BroadcastStatusResponse { - service: "rustwave", - broadcaster_connected, - channet_connected, - broadcaster_url: state.broadcaster_url.clone(), - queue_depth, - }) -} - -async fn check_broadcaster_reachable(url: &str) -> bool { - match reqwest::Client::new() - .get(url) - .timeout(std::time::Duration::from_secs(2)) - .send() - .await - { - Ok(r) => r.status().is_success(), - Err(_) => false, - } -} - -// ── POST /broadcast/transmit ─────────────────────────────────────────────── - -pub async fn broadcast_transmit( - State(state): State, - mut multipart: Multipart, -) -> Result, ApiError> { - let (filename, file_bytes) = extract_file_field(&mut multipart).await?; - - info!(filename = %filename, input_bytes = file_bytes.len(), "POST /broadcast/transmit received file"); - - let wav_bytes: Vec = tokio::task::spawn_blocking({ - let filename = filename.clone(); - move || { - let framed = framer::frame(&file_bytes, &filename); - let samples = encoder::encode(&framed); - wav::write_to_bytes(&samples) - } - }) - .await - .map_err(|e| ApiError::Internal(format!("task panic: {e}")))? - .map_err(ApiError::EncodeFailed)?; - - let wav_size = wav_bytes.len(); - let tx_id = Uuid::new_v4(); - - info!(%tx_id, wav_bytes = wav_size, broadcaster_url = %state.broadcaster_url, - "POST /broadcast/transmit forwarding to Broadcaster"); - - forward_to_broadcaster(&state.broadcaster_url, &filename, wav_bytes, tx_id).await?; - - Ok(Json(TransmitResponse { status: "ok", tx_id, wav_bytes: wav_size })) -} - -pub async fn forward_to_broadcaster( - broadcaster_url: &str, - original_filename: &str, - wav_bytes: Vec, - tx_id: Uuid, -) -> Result<(), ApiError> { - let stem = std::path::Path::new(original_filename) - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .into_owned(); - let wav_filename = format!("{stem}_encoded.wav"); - - let part = reqwest::multipart::Part::bytes(wav_bytes) - .file_name(wav_filename) - .mime_str("audio/wav") - .map_err(|e| ApiError::Internal(format!("mime error: {e}")))?; - let form = reqwest::multipart::Form::new().part("file", part); - - let resp = reqwest::Client::new() - .post(broadcaster_url) - .multipart(form) - .send() - .await - .map_err(|e| ApiError::BroadcasterUnavailable( - format!("could not reach Broadcaster at {broadcaster_url}: {e}") - ))?; - - if !resp.status().is_success() { - return Err(ApiError::BroadcasterUnavailable(format!( - "Broadcaster returned HTTP {} for tx_id {tx_id}", resp.status() - ))); - } - - debug!(%tx_id, "Broadcaster accepted WAV"); - Ok(()) -} - -// ── POST /broadcast/receive ──────────────────────────────────────────────── - -pub async fn broadcast_receive( - State(state): State, - mut multipart: Multipart, -) -> Result, ApiError> { - let (_filename, wav_bytes) = extract_file_field(&mut multipart).await?; - - info!(wav_bytes = wav_bytes.len(), "POST /broadcast/receive decoding WAV"); - - let decoded = tokio::task::spawn_blocking(move || { - let samples = wav::read_from_bytes(&wav_bytes)?; - decoder::decode(&samples) - }) - .await - .map_err(|e| ApiError::Internal(format!("task panic: {e}")))? - .map_err(ApiError::DecodeFailed)?; - - let decoded_size = decoded.data.len(); - let queued_id = Uuid::new_v4(); - - info!( - %queued_id, - original_filename = %decoded.filename, - decoded_bytes = decoded_size, - "POST /broadcast/receive queuing decoded file" - ); - - state.enqueue(QueuedFile { queued_id, bytes: Bytes::from(decoded.data) }).await; - - Ok(Json(ReceiveResponse { status: "ok", queued_id, decoded_bytes: decoded_size })) -} - -// ── GET /broadcast/incoming ──────────────────────────────────────────────── - -pub async fn broadcast_incoming(State(state): State) -> Response { - match state.dequeue().await { - Some(file) => { - info!(queued_id = %file.queued_id, bytes = file.bytes.len(), - "GET /broadcast/incoming dequeuing file"); - ( - StatusCode::OK, - [ - (header::CONTENT_TYPE, "application/octet-stream"), - (header::CONTENT_DISPOSITION, "attachment; filename=\"snapshot.zip\""), - ], - file.bytes, - ) - .into_response() - } - None => { - debug!("GET /broadcast/incoming queue is empty"); - (StatusCode::OK, Json(QueueEmptyResponse { status: "empty" })).into_response() - } - } -} - -// ── Shared helper ────────────────────────────────────────────────────────── - -async fn extract_file_field( - multipart: &mut Multipart, -) -> Result<(String, Vec), ApiError> { - while let Some(field) = multipart - .next_field() - .await - .map_err(|e| ApiError::BadRequest(format!("multipart error: {e}")))? - { - let filename = field.file_name().unwrap_or("upload").to_string(); - let data = field - .bytes() - .await - .map_err(|e| ApiError::BadRequest(format!("could not read field bytes: {e}")))?; - - if data.is_empty() { - warn!(filename = %filename, "received empty file field"); - return Err(ApiError::BadRequest("file field is empty".into())); - } - - return Ok((filename, data.to_vec())); - } - - Err(ApiError::BadRequest("no file field found in multipart body".into())) -} -``` - -```bash -cargo check -``` - -**Commit:** `feat: add api/broadcast.rs handlers` - ---- - -### Step 7.5 — `src/api/chan.rs` *(CREATE)* - -```rust -//! ChanNet HTTP client and /chan/request proxy handler. -//! -//! ChanNet already calls RustWave on: -//! POST /broadcast/transmit — pushes a ZIP snapshot for AFSK encoding & over-air transmission -//! GET /broadcast/incoming — pulls decoded ZIP snapshots that arrived over radio -//! -//! This module adds the outbound direction: -//! POST /chan/request — operator sends a typed ChanCommand; RustWave forwards it to -//! ChanNet's /chan/command, receives the ZIP response, AFSK-encodes -//! it into WAV, and calls forward_to_broadcaster() for transmission. - -use axum::{extract::State, Json}; -use tracing::info; -use uuid::Uuid; - -use crate::api::{ - errors::ApiError, - models::{ChanCommand, ChanRequestResponse}, - state::AppState, -}; -use crate::{encoder, framer, wav}; - -// ── Reachability probe ───────────────────────────────────────────────────── - -/// Hits ChanNet's GET /chan/status. Used by broadcast_status to report -/// whether the paired ChanNet node is reachable. -pub async fn check_channet_reachable(channet_url: &str) -> bool { - match reqwest::Client::new() - .get(format!("{channet_url}/chan/status")) - .timeout(std::time::Duration::from_secs(2)) - .send() - .await - { - Ok(r) => r.status().is_success(), - Err(_) => false, - } -} - -// ── ChanNet command client ───────────────────────────────────────────────── - -/// POST a typed ChanCommand to ChanNet's /chan/command endpoint. -/// Returns the raw ZIP bytes from the response body. -pub async fn send_chan_command( - channet_url: &str, - command: &ChanCommand, -) -> Result { - let resp = reqwest::Client::new() - .post(format!("{channet_url}/chan/command")) - .json(command) - .send() - .await - .map_err(|e| ApiError::BroadcasterUnavailable( - format!("ChanNet unreachable at {channet_url}: {e}") - ))?; - - if !resp.status().is_success() { - return Err(ApiError::BroadcasterUnavailable( - format!("ChanNet /chan/command returned HTTP {}", resp.status()), - )); - } - - resp.bytes() - .await - .map_err(|e| ApiError::Internal(format!("reading ChanNet response body: {e}"))) -} - -// ── POST /chan/request ───────────────────────────────────────────────────── - -pub async fn chan_request( - State(state): State, - Json(command): Json, -) -> Result, ApiError> { - info!(?command, channet_url = %state.channet_url, "POST /chan/request"); - - // 1. Fetch ZIP from ChanNet. - let zip_bytes = send_chan_command(&state.channet_url, &command).await?; - let zip_len = zip_bytes.len(); - - // 2. AFSK-encode the ZIP into WAV bytes (CPU-bound; run on blocking thread). - let wav_bytes: Vec = tokio::task::spawn_blocking(move || { - let framed = framer::frame(&zip_bytes, "channet_payload.zip"); - let samples = encoder::encode(&framed); - wav::write_to_bytes(&samples) - }) - .await - .map_err(|e| ApiError::Internal(format!("task panic: {e}")))? - .map_err(ApiError::EncodeFailed)?; - - // 3. Forward the WAV to the external Broadcaster for over-air transmission. - // Reuses the same helper as /broadcast/transmit. - let tx_id = Uuid::new_v4(); - crate::api::broadcast::forward_to_broadcaster( - &state.broadcaster_url, - "channet_payload.zip", - wav_bytes, - tx_id, - ) - .await?; - - info!(%tx_id, zip_bytes = zip_len, "POST /chan/request transmitted successfully"); - - Ok(Json(ChanRequestResponse { - status: "transmitted", - tx_id, - zip_bytes: zip_len, - })) -} -``` - -```bash -cargo check -``` - -**Commit:** `feat: add api/chan.rs — ChanNet client and /chan/request handler` - ---- - -```rust -//! RustWave HTTP API server. -//! -//! full_router() — /wave/* + /broadcast/* + /chan/* (serve subcommand) -//! gui_router() — /broadcast/* + /chan/* (gui subcommand) - -pub mod broadcast; -pub mod chan; -pub mod errors; -pub mod models; -pub mod state; -pub mod wave; - -use axum::{routing::get, routing::post, Router}; -use std::net::SocketAddr; -use tower_http::limit::RequestBodyLimitLayer; -use tracing::info; - -use state::AppState; - -const BODY_LIMIT: usize = 10 * 1024 * 1024; // 10 MB - -pub fn full_router(state: AppState) -> Router { - let wave_routes = Router::new() - .route("/wave/status", get(wave::wave_status)) - .route("/wave/encode", post(wave::wave_encode)) - .route("/wave/decode", post(wave::wave_decode)); - - Router::new() - .merge(wave_routes) - .merge(broadcast_routes()) - .merge(chan_routes()) - .layer(RequestBodyLimitLayer::new(BODY_LIMIT)) - .with_state(state) -} - -pub fn gui_router(state: AppState) -> Router { - Router::new() - .merge(broadcast_routes()) - .merge(chan_routes()) - .layer(RequestBodyLimitLayer::new(BODY_LIMIT)) - .with_state(state) -} - -fn broadcast_routes() -> Router { - Router::new() - .route("/broadcast/status", get(broadcast::broadcast_status)) - .route("/broadcast/transmit", post(broadcast::broadcast_transmit)) - .route("/broadcast/receive", post(broadcast::broadcast_receive)) - .route("/broadcast/incoming", get(broadcast::broadcast_incoming)) -} - -fn chan_routes() -> Router { - Router::new() - .route("/chan/request", post(chan::chan_request)) -} - -pub async fn run_server(router: Router, bind_addr: SocketAddr) -> anyhow::Result<()> { - let listener = tokio::net::TcpListener::bind(bind_addr).await?; - info!(addr = %bind_addr, "RustWave API server listening"); - axum::serve(listener, router).await?; - Ok(()) -} -``` - -> **Note:** `run_server` uses `anyhow::Result` — add `anyhow = "1"` to `Cargo.toml` if not already present. - -```bash -cargo check -``` - -**Commit:** `feat: add api/mod.rs — router builder and run_server` - ---- - -### Step 9 — `src/main.rs` *(EDIT — full replacement)* - -```rust -mod api; -mod config; -mod decoder; -mod encoder; -mod framer; -mod gui; -mod logging; -mod wav; - -use clap::{Parser, Subcommand}; -use std::{net::SocketAddr, path::PathBuf}; - -#[derive(Parser)] -#[command(name = "rustwave-cli", version, about = "RustWave audio codec", long_about = None)] -struct Cli { - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand)] -enum Command { - /// Launch the drag-and-drop GUI (also starts /broadcast/* API on 127.0.0.1:7071) - Gui, - /// Start the HTTP API server (both /wave/* and /broadcast/* on 127.0.0.1:7071) - Serve { - #[arg(short, long, value_name = "ADDR")] - bind: Option, - }, - /// Encode a file into an AFSK WAV - Encode { - #[arg(short, long, value_name = "FILE")] - input: PathBuf, - #[arg(short, long, value_name = "FILE")] - output: PathBuf, - }, - /// Decode an AFSK WAV — restores the original filename automatically - Decode { - #[arg(short, long, value_name = "FILE")] - input: PathBuf, - #[arg(short, long, value_name = "FILE")] - output: Option, - }, -} - -fn main() { - let _log_guard = logging::init(); - if let Err(e) = run() { - eprintln!("error: {e}"); - std::process::exit(1); - } -} - -fn run() -> Result<(), String> { - let cli = Cli::parse(); - - match cli.command { - Command::Gui => { - gui::run().map_err(|e| format!("GUI error: {e}"))?; - } - - Command::Serve { bind } => { - let addr = bind - .or_else(|| std::env::var("RUSTWAVE_BIND").ok().and_then(|s| s.parse().ok())) - .unwrap_or_else(|| "127.0.0.1:7071".parse().unwrap()); - - let rt = tokio::runtime::Runtime::new() - .map_err(|e| format!("failed to build Tokio runtime: {e}"))?; - - rt.block_on(async move { - let state = api::state::AppState::new(true); - let router = api::full_router(state); - api::run_server(router, addr) - .await - .map_err(|e| format!("server error: {e}")) - })?; - } - - Command::Encode { input, output } => { - let data = std::fs::read(&input) - .map_err(|e| format!("cannot read '{}': {e}", input.display()))?; - let filename = input.file_name().unwrap_or_default().to_string_lossy().into_owned(); - let framed = framer::frame(&data, &filename); - let samples = encoder::encode(&framed); - wav::write(&output, &samples) - .map_err(|e| format!("cannot write '{}': {e}", output.display()))?; - #[allow(clippy::cast_precision_loss)] - let duration = samples.len() as f64 / f64::from(config::SAMPLE_RATE); - eprintln!("encoded '{}' ({} byte{}) -> {} ({duration:.2} s)", - filename, data.len(), plural(data.len()), output.display()); - } - - Command::Decode { input, output } => { - let samples = wav::read(&input) - .map_err(|e| format!("cannot read '{}': {e}", input.display()))?; - let decoded = decoder::decode(&samples).map_err(|e| format!("decode failed: {e}"))?; - let out_path = output.unwrap_or_else(|| { - input.parent().unwrap_or_else(|| std::path::Path::new(".")).join(&decoded.filename) - }); - std::fs::write(&out_path, &decoded.data) - .map_err(|e| format!("cannot write '{}': {e}", out_path.display()))?; - eprintln!("decoded {} byte{} -> '{}' (original filename: '{}')", - decoded.data.len(), plural(decoded.data.len()), out_path.display(), decoded.filename); - } - } - - Ok(()) -} - -const fn plural(n: usize) -> &'static str { - if n == 1 { "" } else { "s" } -} -``` - -```bash -cargo check -cargo test -./target/debug/rustwave-cli --help -``` - -**Commit:** `feat: add serve subcommand and wire logging in main.rs` - ---- - -### Step 10 — `src/gui.rs` *(EDIT — replace `pub fn run()` only)* - -Find the existing `pub fn run() -> eframe::Result<()>` function at the bottom of `gui.rs` and replace it with: - -```rust -pub fn run() -> eframe::Result<()> { - // Spawn the /broadcast/* API server on a background OS thread. - std::thread::spawn(|| { - let addr: std::net::SocketAddr = std::env::var("RUSTWAVE_BIND") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or_else(|| "127.0.0.1:7071".parse().unwrap()); - - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("failed to build Tokio runtime for GUI API server"); - - rt.block_on(async move { - let state = crate::api::state::AppState::new(false); - let router = crate::api::gui_router(state); - if let Err(e) = crate::api::run_server(router, addr).await { - tracing::error!("GUI API server error: {e}"); - } - }); - }); - - tracing::info!("GUI mode: /broadcast/* API started on 127.0.0.1:7071"); - - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_inner_size([480.0, 340.0]) - .with_min_inner_size([360.0, 260.0]) - .with_title("RustWave") - .with_drag_and_drop(true), - ..Default::default() - }; - - eframe::run_native( - "RustWave", - options, - Box::new(|cc| Ok(Box::new(AfskGui::new(cc)) as Box)), - ) -} -``` - -**No other changes to `gui.rs`.** All existing drag-and-drop, progress bar, and encode/decode logic is untouched. - -```bash -cargo check -cargo clippy -``` - -**Commit:** `feat: spawn broadcast API server in GUI mode` - ---- - -### Step 11 — `src/wav.rs` *(EDIT — append before `#[cfg(test)]`)* - -Add to the bottom of `src/wav.rs`, before the existing `#[cfg(test)]` block: - -```rust -// ── In-memory variants used by the HTTP API ────────────────────────────── - -pub fn write_to_bytes(samples: &[f64]) -> Result, String> { - use std::io::Cursor; - let spec = hound::WavSpec { - channels: 1, - sample_rate: SAMPLE_RATE, - bits_per_sample: 16, - sample_format: hound::SampleFormat::Int, - }; - - let mut buf: Vec = Vec::new(); - let cursor = Cursor::new(&mut buf); - let mut writer = hound::WavWriter::new(cursor, spec).map_err(|e| e.to_string())?; - - for &s in samples { - #[allow(clippy::cast_possible_truncation)] - let v = (s.clamp(-1.0, 1.0) * 32_767.0) as i16; - writer.write_sample(v).map_err(|e| e.to_string())?; - } - - writer.finalize().map_err(|e| e.to_string())?; - Ok(buf) -} - -pub fn read_from_bytes(data: &[u8]) -> Result, String> { - use std::io::Cursor; - let cursor = Cursor::new(data); - let mut reader = hound::WavReader::new(cursor).map_err(|e| e.to_string())?; - let spec = reader.spec(); - - match (spec.bits_per_sample, spec.sample_format) { - (16, hound::SampleFormat::Int) => { - let channels = usize::from(spec.channels); - if channels == 0 { - return Err("invalid WAV: 0 channels".into()); - } - reader - .samples::() - .step_by(channels) - .map(|s| s.map(|v| f64::from(v) / 32_768.0).map_err(|e| e.to_string())) - .collect() - } - (bits, fmt) => Err(format!( - "unsupported WAV format: {bits}-bit {fmt:?} (rustwave-cli expects 16-bit integer PCM)" - )), - } -} -``` - -Also add inside the existing `#[cfg(test)]` block: - -```rust -#[test] -fn memory_round_trip() -> Result<(), String> { - use std::f64::consts::TAU; - #[allow(clippy::cast_precision_loss)] - let original: Vec = (0..4_410_i32) - .map(|i| 0.5 * (TAU * 440.0 * f64::from(i) / 44_100.0).sin()) - .collect(); - let bytes = write_to_bytes(&original)?; - let recovered = read_from_bytes(&bytes)?; - assert_eq!(original.len(), recovered.len()); - for (a, b) in original.iter().zip(recovered.iter()) { - assert!((a - b).abs() < 5e-5, "quantisation error: {a} vs {b}"); - } - Ok(()) -} -``` - -```bash -cargo check -cargo test -``` - -**Commit:** `feat: add wav::write_to_bytes and read_from_bytes for API use` - ---- - -### Step 12 — `src/api/tests.rs` *(CREATE)* - -```rust -#[cfg(test)] -mod tests { - use crate::api::state::AppState; - - #[tokio::test] - async fn queue_enqueue_dequeue() { - use crate::api::state::QueuedFile; - use bytes::Bytes; - use uuid::Uuid; - - let state = AppState::new(false); - assert_eq!(state.queue_depth().await, 0); - - state.enqueue(QueuedFile { - queued_id: Uuid::new_v4(), - bytes: Bytes::from_static(b"hello"), - }).await; - - assert_eq!(state.queue_depth().await, 1); - let file = state.dequeue().await.unwrap(); - assert_eq!(file.bytes.as_ref(), b"hello"); - assert!(state.dequeue().await.is_none()); - } -} -``` - -```bash -cargo test -``` - -**Commit:** `test: add integration test script and api/tests.rs` - ---- - -## Final Verification - -```bash -cargo deny check -cargo build --release -git tag v0.2.0-api -``` - ---- - -## Quick Reference — Route Table - -| Mode | Route | Method | Registered? | -|---|---|---|---| -| `serve` | `/wave/status` | GET | ✓ | -| `serve` | `/wave/encode` | POST | ✓ | -| `serve` | `/wave/decode` | POST | ✓ | -| `serve` + `gui` | `/broadcast/status` | GET | ✓ | -| `serve` + `gui` | `/broadcast/transmit` | POST | ✓ | -| `serve` + `gui` | `/broadcast/receive` | POST | ✓ | -| `serve` + `gui` | `/broadcast/incoming` | GET | ✓ | -| `serve` + `gui` | `/chan/request` | POST | ✓ | -| `gui` | `/wave/*` | any | ✗ (404) | - -**ChanNet ↔ RustWave call directions:** -- *ChanNet → RustWave:* ChanNet's `/chan/refresh` posts ZIP snapshots to `POST /broadcast/transmit`; ChanNet's `/chan/poll` reads decoded inbound payloads from `GET /broadcast/incoming`. No new routes are needed for this direction. -- *RustWave → ChanNet:* `POST /chan/request` accepts a `ChanCommand` JSON body, forwards it to ChanNet's `/chan/command`, AFSK-encodes the returned ZIP, and forwards the WAV to the external Broadcaster for over-air transmission. - -## Environment Variables - -| Variable | Default | Description | -|---|---|---| -| `RUSTWAVE_BIND` | `127.0.0.1:7071` | API server bind address | -| `RUSTWAVE_BROADCASTER_URL` | `http://localhost:9090` | URL to forward encoded WAV to | -| `RUSTWAVE_CHANNET_URL` | `http://localhost:7070` | Base URL of the paired ChanNet node | -| `RUSTWAVE_LOG` | `info` | stderr log filter (tracing-subscriber syntax) | diff --git a/rustwave-api/rustwave_api_staged_build_prompt.md b/rustwave-api/rustwave_api_staged_build_prompt.md deleted file mode 100644 index 42a8c11..0000000 --- a/rustwave-api/rustwave_api_staged_build_prompt.md +++ /dev/null @@ -1,1462 +0,0 @@ -# RustWave HTTP API — Staged Build Prompt -*Synthesised from `rustwave_api_build_plan.md` + `RustWave-tree-annotated.txt` · March 2026* - ---- - -## How to Use This Document - -Hand this file (or a single stage from it) to an AI coding assistant. Each stage ends with a mandatory `cargo check` gate — do **not** proceed until it is green. Commit after every stage. The stages are ordered to keep the dependency graph clean: types before implementations, infrastructure before handlers, handlers before integration. - ---- - -## Project Context - -RustWave is a Rust application that AFSK-encodes arbitrary file payloads into 1 200-baud WAV audio and decodes them back. The existing codebase has: - -- `src/config.rs` — shared constants (44 100 Hz, 1 200 baud, MARK/SPACE frequencies) -- `src/encoder.rs` — bytes → PCM sine-wave synthesis -- `src/decoder.rs` — PCM → bytes via Goertzel-filter bit detection -- `src/framer.rs` — wire-frame builder/parser (preamble + sync + CRC-16/CCITT) -- `src/wav.rs` — WAV I/O via `hound` (file-based only, today) -- `src/gui.rs` — egui drag-and-drop front-end -- `src/main.rs` — CLI entry point (`gui`, `encode`, `decode` subcommands) - -**Goal:** Add an axum HTTP API with three route groups: - -| Group | Routes | Available in | -|---|---|---| -| `/wave/*` | encode, decode, status | `serve` mode only | -| `/broadcast/*` | transmit, receive, incoming, status | `serve` + `gui` | -| `/chan/*` | request (ChanNet proxy) | `serve` + `gui` | - -**Files that must NOT be touched:** `src/config.rs`, `src/encoder.rs`, `src/decoder.rs`, `src/framer.rs`, `deny.toml`. - ---- - -## Environment Variables (reference throughout) - -| Variable | Default | Description | -|---|---|---| -| `RUSTWAVE_BIND` | `127.0.0.1:7071` | API server bind address | -| `RUSTWAVE_BROADCASTER_URL` | `http://localhost:9090` | URL to forward encoded WAV to | -| `RUSTWAVE_CHANNET_URL` | `http://localhost:7070` | Base URL of the paired ChanNet node | -| `RUSTWAVE_LOG` | `info` | stderr log filter (tracing-subscriber syntax) | - ---- - -## Final Route Table (reference throughout) - -| Mode | Route | Method | -|---|---|---| -| `serve` | `/wave/status` | GET | -| `serve` | `/wave/encode` | POST multipart | -| `serve` | `/wave/decode` | POST multipart | -| `serve` + `gui` | `/broadcast/status` | GET | -| `serve` + `gui` | `/broadcast/transmit` | POST multipart | -| `serve` + `gui` | `/broadcast/receive` | POST multipart | -| `serve` + `gui` | `/broadcast/incoming` | GET | -| `serve` + `gui` | `/chan/request` | POST JSON | -| `gui` | `/wave/*` | 404 | - ---- - ---- - -# STAGE 1 — Dependencies (`Cargo.toml`) - -**What:** Add all new crate dependencies. Nothing else changes. - -**Why first:** Every subsequent stage imports these crates; the project won't compile at all without them. - -Replace the entire `[dependencies]` block in `Cargo.toml` with: - -```toml -[package] -name = "rustwave" -version = "0.1.0" -edition = "2021" -license = "MIT" -description = "RustWave audio codec — encode bytes to WAV, decode WAV to bytes" - -[[bin]] -name = "rustwave-cli" -path = "src/main.rs" - -[dependencies] -# — existing — -clap = { version = "4", features = ["derive"] } -hound = "3" -eframe = "0.31" - -# — NEW: async runtime (required for axum) — -tokio = { version = "1", features = ["full"] } - -# — NEW: HTTP server — -axum = { version = "0.7", features = ["multipart"] } -tower = "0.4" -tower-http = { version = "0.5", features = ["cors", "limit"] } - -# — NEW: HTTP client (forward WAV to Broadcaster) — -reqwest = { version = "0.12", features = ["multipart"] } - -# — NEW: serialisation — -serde = { version = "1", features = ["derive"] } -serde_json = "1" - -# — NEW: UUID generation for tx_id / queued_id — -uuid = { version = "1", features = ["v4"] } - -# — NEW: logging / tracing — -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } -tracing-appender = "0.2" - -# — NEW: in-memory byte buffers (WAV bytes without temp files) — -bytes = "1" - -# — NEW: error propagation in run_server — -anyhow = "1" -``` - -```bash -cargo check # must be green before continuing -``` - -**Commit:** `chore: add API dependencies to Cargo.toml` - ---- - ---- - -# STAGE 2 — Logging (`src/logging.rs`) — CREATE - -**What:** Logging initialisation module. Writes human-readable INFO+ to stderr and rolling JSON DEBUG+ to a daily log file. Respects `RUSTWAVE_LOG` env var. - -**Why here:** `logging::init()` will be called at the top of `main()`. It must exist before `main.rs` is edited. - -Create `src/logging.rs`: - -```rust -//! Logging initialisation for RustWave. -//! -//! Call `logging::init()` once at the start of `main()`. -//! -//! Log output: -//! - stderr: INFO and above, human-readable -//! - rustwave.log (file): DEBUG and above, JSON format, rolling daily -//! -//! The log file is written next to the binary. -//! Set RUSTWAVE_LOG=debug to see debug output on stderr too. - -use std::path::PathBuf; -use tracing_appender::rolling; -use tracing_subscriber::{ - fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, -}; - -/// Initialise logging. Must be called once before any tracing macros are used. -/// -/// Returns the `_guard` from `tracing_appender::non_blocking`. The caller MUST -/// hold this value for the lifetime of the process; dropping it flushes and -/// closes the log file. -pub fn init() -> tracing_appender::non_blocking::WorkerGuard { - let log_dir: PathBuf = std::env::current_exe() - .ok() - .and_then(|p| p.parent().map(PathBuf::from)) - .unwrap_or_else(|| PathBuf::from(".")); - - // Rolling daily log file: rustwave.YYYY-MM-DD - let file_appender = rolling::daily(&log_dir, "rustwave.log"); - let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); - - // stderr layer — human readable, INFO+ by default, respects RUSTWAVE_LOG - let stderr_filter = EnvFilter::try_from_env("RUSTWAVE_LOG") - .unwrap_or_else(|_| EnvFilter::new("info")); - - let stderr_layer = fmt::layer() - .with_target(false) - .with_filter(stderr_filter); - - // file layer — JSON, DEBUG+ - let file_layer = fmt::layer() - .json() - .with_writer(non_blocking) - .with_filter(EnvFilter::new("debug")); - - tracing_subscriber::registry() - .with(stderr_layer) - .with(file_layer) - .init(); - - guard -} -``` - -```bash -cargo check -``` - -**Commit:** `feat: add logging module` - ---- - ---- - -# STAGE 3 — API Types (`src/api/models.rs`) — CREATE - -**What:** All JSON request and response structs for every API route. Also defines `ChanCommand`, the tagged enum that mirrors ChanNet's `/chan/command` API exactly. - -**Why here:** Every subsequent API module (`errors`, `state`, `wave`, `broadcast`, `chan`) imports from `models`. It must exist before any of them. - -```bash -mkdir src/api -``` - -Create `src/api/models.rs`: - -```rust -//! JSON request and response types for the RustWave API. - -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -// ── /wave/* responses ────────────────────────────────────────────────────── - -#[derive(Serialize)] -pub struct WaveStatusResponse { - pub service: &'static str, - pub codec: &'static str, - pub version: &'static str, -} - -// ── /broadcast/* responses ───────────────────────────────────────────────── - -#[derive(Serialize)] -pub struct BroadcastStatusResponse { - pub service: &'static str, - pub broadcaster_connected: bool, - pub channet_connected: bool, - pub broadcaster_url: String, - pub queue_depth: usize, -} - -#[derive(Serialize)] -pub struct TransmitResponse { - pub status: &'static str, - pub tx_id: Uuid, - pub wav_bytes: usize, -} - -#[derive(Serialize)] -pub struct ReceiveResponse { - pub status: &'static str, - pub queued_id: Uuid, - pub decoded_bytes: usize, -} - -/// Returned by GET /broadcast/incoming when the queue is empty. -#[derive(Serialize, Deserialize)] -pub struct QueueEmptyResponse { - pub status: &'static str, -} - -// ── ChanNet /chan/command request types ──────────────────────────────────── -// -// Mirrors the six commands defined in the ChanNet API reference exactly. -// The `type` field is serialised as the serde tag so the JSON sent to -// /chan/command matches the format ChanNet expects. - -#[derive(Serialize, Deserialize, Debug)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ChanCommand { - /// All boards + all active (non-archived) posts. Optional delta via `since`. - FullExport { since: Option }, - /// All active posts on a single board. Optional delta via `since`. - BoardExport { board: String, since: Option }, - /// All posts in a single thread. Optional delta via `since`. - ThreadExport { thread_id: u64, since: Option }, - /// All archived threads + posts for a single board. Always a full export. - ArchiveExport { board: String }, - /// Entire database — all boards, threads, archives, posts. Use for initial - /// sync or recovery only; RustChan logs a warning when this is received. - ForceRefresh, - /// Post a new reply to an existing thread (the only write command). - ReplyPush { - board: String, - thread_id: u64, - author: String, - content: String, - timestamp: u64, - }, -} - -/// Returned by POST /chan/request on success. -#[derive(Serialize)] -pub struct ChanRequestResponse { - pub status: &'static str, // "transmitted" - pub tx_id: uuid::Uuid, - pub zip_bytes: usize, -} - -// ── Error envelope ───────────────────────────────────────────────────────── - -#[derive(Serialize)] -pub struct ErrorDetail { - pub code: String, - pub message: String, - pub status: u16, -} - -#[derive(Serialize)] -pub struct ErrorEnvelope { - pub error: ErrorDetail, -} -``` - -```bash -cargo check -``` - -**Commit:** `feat: add api/models.rs` - ---- - ---- - -# STAGE 4 — Error Handling (`src/api/errors.rs`) — CREATE - -**What:** `ApiError` enum covering every failure mode. Implements axum's `IntoResponse` so handlers can return `Result<_, ApiError>` directly. Logs every error via `tracing`. - -**Why here:** Every handler module uses `ApiError`. It must be defined before the handlers. - -Create `src/api/errors.rs`: - -```rust -//! API error type for RustWave. -//! -//! Every handler returns `Result<_, ApiError>`. axum automatically calls -//! `IntoResponse` on the error path. - -use axum::{ - http::StatusCode, - response::{IntoResponse, Response}, - Json, -}; -use crate::api::models::{ErrorDetail, ErrorEnvelope}; - -#[derive(Debug)] -pub enum ApiError { - BadRequest(String), - PayloadTooLarge, - EncodeFailed(String), - DecodeFailed(String), - BroadcasterUnavailable(String), - Internal(String), -} - -impl ApiError { - fn code(&self) -> &'static str { - match self { - Self::BadRequest(_) => "BAD_REQUEST", - Self::PayloadTooLarge => "PAYLOAD_TOO_LARGE", - Self::EncodeFailed(_) => "ENCODE_FAILED", - Self::DecodeFailed(_) => "DECODE_FAILED", - Self::BroadcasterUnavailable(_) => "BROADCASTER_UNAVAILABLE", - Self::Internal(_) => "INTERNAL_ERROR", - } - } - - fn status_code(&self) -> StatusCode { - match self { - Self::BadRequest(_) => StatusCode::BAD_REQUEST, - Self::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE, - Self::EncodeFailed(_) => StatusCode::UNPROCESSABLE_ENTITY, - Self::DecodeFailed(_) => StatusCode::UNPROCESSABLE_ENTITY, - Self::BroadcasterUnavailable(_) => StatusCode::BAD_GATEWAY, - Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, - } - } -} - -impl IntoResponse for ApiError { - fn into_response(self) -> Response { - let status = self.status_code(); - let message = match &self { - Self::BadRequest(m) => m.clone(), - Self::PayloadTooLarge => "Request body exceeds the 10 MB limit.".into(), - Self::EncodeFailed(m) => m.clone(), - Self::DecodeFailed(m) => m.clone(), - Self::BroadcasterUnavailable(m) => m.clone(), - Self::Internal(m) => m.clone(), - }; - - tracing::error!( - code = self.code(), - http_status = status.as_u16(), - %message, - "api error" - ); - - let body = ErrorEnvelope { - error: ErrorDetail { - code: self.code().into(), - message, - status: status.as_u16(), - }, - }; - - (status, Json(body)).into_response() - } -} -``` - -```bash -cargo check -``` - -**Commit:** `feat: add api/errors.rs` - ---- - ---- - -# STAGE 5 — Shared State (`src/api/state.rs`) — CREATE - -**What:** `AppState` (holds broadcaster/channet URLs and the incoming queue), `IncomingQueue` type alias, and `QueuedFile`. `AppState` is `Clone` and is injected into every axum handler via `State`. - -**Why here:** The router (`mod.rs`) and every handler depend on `AppState`. Define it before either. - -Create `src/api/state.rs`: - -```rust -//! Shared state for the RustWave API server. - -use std::{collections::VecDeque, sync::Arc}; -use bytes::Bytes; -use tokio::sync::Mutex; -use uuid::Uuid; - -#[derive(Debug)] -pub struct QueuedFile { - pub queued_id: Uuid, - pub bytes: Bytes, -} - -pub type IncomingQueue = Arc>>; - -#[derive(Clone)] -pub struct AppState { - pub broadcaster_url: String, - pub channet_url: String, - pub wave_routes_enabled: bool, - pub incoming_queue: IncomingQueue, -} - -impl AppState { - pub fn new(wave_routes_enabled: bool) -> Self { - let broadcaster_url = std::env::var("RUSTWAVE_BROADCASTER_URL") - .unwrap_or_else(|_| "http://localhost:9090".to_string()); - - let channet_url = std::env::var("RUSTWAVE_CHANNET_URL") - .unwrap_or_else(|_| "http://localhost:7070".to_string()); - - Self { - broadcaster_url, - channet_url, - wave_routes_enabled, - incoming_queue: Arc::new(Mutex::new(VecDeque::new())), - } - } - - pub async fn queue_depth(&self) -> usize { - self.incoming_queue.lock().await.len() - } - - pub async fn enqueue(&self, file: QueuedFile) { - self.incoming_queue.lock().await.push_back(file); - } - - pub async fn dequeue(&self) -> Option { - self.incoming_queue.lock().await.pop_front() - } -} -``` - -```bash -cargo check -``` - -**Commit:** `feat: add api/state.rs` - ---- - ---- - -# STAGE 6 — Wave Handlers (`src/api/wave.rs`) — CREATE - -**What:** Three handlers for the general-purpose codec endpoints. Only mounted in `serve` mode (not GUI). All CPU-bound work (framing, encoding, decoding) is offloaded to `tokio::task::spawn_blocking`. - -**Routes implemented:** -- `GET /wave/status` → JSON version info -- `POST /wave/encode` → multipart file in → WAV bytes out -- `POST /wave/decode` → multipart WAV in → original file bytes out - -**Dependency note:** This module calls `wav::write_to_bytes` and `wav::read_from_bytes`, which do not exist yet in `wav.rs`. The file will not fully compile until Stage 11 adds those functions. However, it will pass `cargo check` because the functions are declared in scope — they just need to be added to `wav.rs` before `cargo test` and `cargo build` will succeed. - -Create `src/api/wave.rs`: - -```rust -//! Handlers for the /wave/* general-purpose codec endpoints. -//! Only registered in `serve` mode — NOT in GUI mode. - -use axum::{ - extract::Multipart, - http::header, - response::{IntoResponse, Response}, - Json, -}; -use tracing::{info, warn}; - -use crate::{ - api::{errors::ApiError, models::WaveStatusResponse}, - decoder, encoder, framer, wav, -}; - -// ── GET /wave/status ─────────────────────────────────────────────────────── - -pub async fn wave_status() -> Json { - info!("GET /wave/status"); - Json(WaveStatusResponse { - service: "rustwave", - codec: "afsk-1200", - version: env!("CARGO_PKG_VERSION"), - }) -} - -// ── POST /wave/encode ────────────────────────────────────────────────────── - -pub async fn wave_encode(mut multipart: Multipart) -> Result { - let (filename, file_bytes) = extract_file_field(&mut multipart).await?; - - info!(filename = %filename, input_bytes = file_bytes.len(), "POST /wave/encode starting"); - - let result = tokio::task::spawn_blocking(move || { - let framed = framer::frame(&file_bytes, &filename); - let samples = encoder::encode(&framed); - let wav_bytes = wav::write_to_bytes(&samples)?; - Ok::<(String, Vec), String>((filename, wav_bytes)) - }) - .await - .map_err(|e| ApiError::Internal(format!("task panic: {e}")))? - .map_err(ApiError::EncodeFailed)?; - - let (original_filename, wav_bytes) = result; - let stem = std::path::Path::new(&original_filename) - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .into_owned(); - let out_name = format!("{stem}_encoded.wav"); - - info!(output_filename = %out_name, wav_bytes = wav_bytes.len(), "POST /wave/encode complete"); - - Ok(( - [ - (header::CONTENT_TYPE, "audio/wav"), - (header::CONTENT_DISPOSITION, &format!("attachment; filename=\"{out_name}\"")), - ], - wav_bytes, - ) - .into_response()) -} - -// ── POST /wave/decode ────────────────────────────────────────────────────── - -pub async fn wave_decode(mut multipart: Multipart) -> Result { - let (_field_name, wav_bytes) = extract_file_field(&mut multipart).await?; - - info!(wav_bytes = wav_bytes.len(), "POST /wave/decode starting"); - - let result = tokio::task::spawn_blocking(move || { - let samples = wav::read_from_bytes(&wav_bytes)?; - let decoded = decoder::decode(&samples)?; - Ok::(decoded) - }) - .await - .map_err(|e| ApiError::Internal(format!("task panic: {e}")))? - .map_err(ApiError::DecodeFailed)?; - - info!( - original_filename = %result.filename, - decoded_bytes = result.data.len(), - "POST /wave/decode complete" - ); - - Ok(( - [ - (header::CONTENT_TYPE, "application/octet-stream"), - (header::CONTENT_DISPOSITION, &format!("attachment; filename=\"{}\"", result.filename)), - ], - result.data, - ) - .into_response()) -} - -// ── Shared helper ────────────────────────────────────────────────────────── - -async fn extract_file_field( - multipart: &mut Multipart, -) -> Result<(String, Vec), ApiError> { - while let Some(field) = multipart - .next_field() - .await - .map_err(|e| ApiError::BadRequest(format!("multipart error: {e}")))? - { - let filename = field.file_name().unwrap_or("upload").to_string(); - let data = field - .bytes() - .await - .map_err(|e| ApiError::BadRequest(format!("could not read field bytes: {e}")))?; - - if data.is_empty() { - warn!(filename = %filename, "received empty file field"); - return Err(ApiError::BadRequest("file field is empty".into())); - } - - return Ok((filename, data.to_vec())); - } - - Err(ApiError::BadRequest("no file field found in multipart body".into())) -} -``` - -```bash -cargo check -``` - -**Commit:** `feat: add api/wave.rs handlers` - ---- - ---- - -# STAGE 7 — Broadcast Handlers (`src/api/broadcast.rs`) — CREATE - -**What:** Four handlers for the broadcast channel endpoints, available in both `serve` and `gui` modes. Also exports `forward_to_broadcaster()`, a reusable helper that sends a WAV file to the external Broadcaster service via multipart POST. This function is called by both `broadcast_transmit` and `chan_request`. - -**Routes implemented:** -- `GET /broadcast/status` → JSON connectivity report (probes Broadcaster + ChanNet) -- `POST /broadcast/transmit` → file in → AFSK-encode → forward WAV to Broadcaster -- `POST /broadcast/receive` → WAV in → AFSK-decode → enqueue decoded bytes -- `GET /broadcast/incoming` → dequeue one decoded file, or `{"status":"empty"}` - -**ChanNet ↔ RustWave call direction note:** -- ChanNet **→** RustWave: ChanNet's `/chan/refresh` posts ZIP snapshots to `POST /broadcast/transmit`; ChanNet's `/chan/poll` reads decoded payloads from `GET /broadcast/incoming`. -- RustWave **→** ChanNet: handled by Stage 7.5 (`chan.rs`). - -Create `src/api/broadcast.rs`: - -```rust -//! Handlers for the /broadcast/* channel network endpoints. -//! Exposed in both `serve` mode and `gui` mode. - -use axum::{ - extract::{Multipart, State}, - http::{header, StatusCode}, - response::{IntoResponse, Response}, - Json, -}; -use bytes::Bytes; -use tracing::{debug, info, warn}; -use uuid::Uuid; - -use crate::{ - api::{ - errors::ApiError, - models::{BroadcastStatusResponse, QueueEmptyResponse, ReceiveResponse, TransmitResponse}, - state::{AppState, QueuedFile}, - }, - decoder, encoder, framer, wav, -}; - -// ── GET /broadcast/status ────────────────────────────────────────────────── - -pub async fn broadcast_status(State(state): State) -> Json { - let queue_depth = state.queue_depth().await; - let broadcaster_connected = check_broadcaster_reachable(&state.broadcaster_url).await; - let channet_connected = crate::api::chan::check_channet_reachable(&state.channet_url).await; - - info!( - queue_depth, - broadcaster_connected, - channet_connected, - broadcaster_url = %state.broadcaster_url, - channet_url = %state.channet_url, - "GET /broadcast/status" - ); - - Json(BroadcastStatusResponse { - service: "rustwave", - broadcaster_connected, - channet_connected, - broadcaster_url: state.broadcaster_url.clone(), - queue_depth, - }) -} - -async fn check_broadcaster_reachable(url: &str) -> bool { - match reqwest::Client::new() - .get(url) - .timeout(std::time::Duration::from_secs(2)) - .send() - .await - { - Ok(r) => r.status().is_success(), - Err(_) => false, - } -} - -// ── POST /broadcast/transmit ─────────────────────────────────────────────── - -pub async fn broadcast_transmit( - State(state): State, - mut multipart: Multipart, -) -> Result, ApiError> { - let (filename, file_bytes) = extract_file_field(&mut multipart).await?; - - info!(filename = %filename, input_bytes = file_bytes.len(), "POST /broadcast/transmit received file"); - - let wav_bytes: Vec = tokio::task::spawn_blocking({ - let filename = filename.clone(); - move || { - let framed = framer::frame(&file_bytes, &filename); - let samples = encoder::encode(&framed); - wav::write_to_bytes(&samples) - } - }) - .await - .map_err(|e| ApiError::Internal(format!("task panic: {e}")))? - .map_err(ApiError::EncodeFailed)?; - - let wav_size = wav_bytes.len(); - let tx_id = Uuid::new_v4(); - - info!(%tx_id, wav_bytes = wav_size, broadcaster_url = %state.broadcaster_url, - "POST /broadcast/transmit forwarding to Broadcaster"); - - forward_to_broadcaster(&state.broadcaster_url, &filename, wav_bytes, tx_id).await?; - - Ok(Json(TransmitResponse { status: "ok", tx_id, wav_bytes: wav_size })) -} - -pub async fn forward_to_broadcaster( - broadcaster_url: &str, - original_filename: &str, - wav_bytes: Vec, - tx_id: Uuid, -) -> Result<(), ApiError> { - let stem = std::path::Path::new(original_filename) - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .into_owned(); - let wav_filename = format!("{stem}_encoded.wav"); - - let part = reqwest::multipart::Part::bytes(wav_bytes) - .file_name(wav_filename) - .mime_str("audio/wav") - .map_err(|e| ApiError::Internal(format!("mime error: {e}")))?; - let form = reqwest::multipart::Form::new().part("file", part); - - let resp = reqwest::Client::new() - .post(broadcaster_url) - .multipart(form) - .send() - .await - .map_err(|e| ApiError::BroadcasterUnavailable( - format!("could not reach Broadcaster at {broadcaster_url}: {e}") - ))?; - - if !resp.status().is_success() { - return Err(ApiError::BroadcasterUnavailable(format!( - "Broadcaster returned HTTP {} for tx_id {tx_id}", resp.status() - ))); - } - - debug!(%tx_id, "Broadcaster accepted WAV"); - Ok(()) -} - -// ── POST /broadcast/receive ──────────────────────────────────────────────── - -pub async fn broadcast_receive( - State(state): State, - mut multipart: Multipart, -) -> Result, ApiError> { - let (_filename, wav_bytes) = extract_file_field(&mut multipart).await?; - - info!(wav_bytes = wav_bytes.len(), "POST /broadcast/receive decoding WAV"); - - let decoded = tokio::task::spawn_blocking(move || { - let samples = wav::read_from_bytes(&wav_bytes)?; - decoder::decode(&samples) - }) - .await - .map_err(|e| ApiError::Internal(format!("task panic: {e}")))? - .map_err(ApiError::DecodeFailed)?; - - let decoded_size = decoded.data.len(); - let queued_id = Uuid::new_v4(); - - info!( - %queued_id, - original_filename = %decoded.filename, - decoded_bytes = decoded_size, - "POST /broadcast/receive queuing decoded file" - ); - - state.enqueue(QueuedFile { queued_id, bytes: Bytes::from(decoded.data) }).await; - - Ok(Json(ReceiveResponse { status: "ok", queued_id, decoded_bytes: decoded_size })) -} - -// ── GET /broadcast/incoming ──────────────────────────────────────────────── - -pub async fn broadcast_incoming(State(state): State) -> Response { - match state.dequeue().await { - Some(file) => { - info!(queued_id = %file.queued_id, bytes = file.bytes.len(), - "GET /broadcast/incoming dequeuing file"); - ( - StatusCode::OK, - [ - (header::CONTENT_TYPE, "application/octet-stream"), - (header::CONTENT_DISPOSITION, "attachment; filename=\"snapshot.zip\""), - ], - file.bytes, - ) - .into_response() - } - None => { - debug!("GET /broadcast/incoming queue is empty"); - (StatusCode::OK, Json(QueueEmptyResponse { status: "empty" })).into_response() - } - } -} - -// ── Shared helper ────────────────────────────────────────────────────────── - -async fn extract_file_field( - multipart: &mut Multipart, -) -> Result<(String, Vec), ApiError> { - while let Some(field) = multipart - .next_field() - .await - .map_err(|e| ApiError::BadRequest(format!("multipart error: {e}")))? - { - let filename = field.file_name().unwrap_or("upload").to_string(); - let data = field - .bytes() - .await - .map_err(|e| ApiError::BadRequest(format!("could not read field bytes: {e}")))?; - - if data.is_empty() { - warn!(filename = %filename, "received empty file field"); - return Err(ApiError::BadRequest("file field is empty".into())); - } - - return Ok((filename, data.to_vec())); - } - - Err(ApiError::BadRequest("no file field found in multipart body".into())) -} -``` - -```bash -cargo check -``` - -**Commit:** `feat: add api/broadcast.rs handlers` - ---- - ---- - -# STAGE 7.5 — ChanNet Client (`src/api/chan.rs`) — CREATE - -**What:** The outbound direction from RustWave to ChanNet. Provides: -- `check_channet_reachable()` — probes `GET /chan/status`, called by `broadcast_status` -- `send_chan_command()` — POSTs a `ChanCommand` JSON to `/chan/command`, returns raw ZIP bytes -- `chan_request` handler — full pipeline: receive `ChanCommand` → fetch ZIP from ChanNet → AFSK-encode → forward WAV to Broadcaster - -**Route implemented:** `POST /chan/request` - -Create `src/api/chan.rs`: - -```rust -//! ChanNet HTTP client and /chan/request proxy handler. -//! -//! ChanNet already calls RustWave on: -//! POST /broadcast/transmit — pushes a ZIP snapshot for AFSK encoding & over-air transmission -//! GET /broadcast/incoming — pulls decoded ZIP snapshots that arrived over radio -//! -//! This module adds the outbound direction: -//! POST /chan/request — operator sends a typed ChanCommand; RustWave forwards it to -//! ChanNet's /chan/command, receives the ZIP response, AFSK-encodes -//! it into WAV, and calls forward_to_broadcaster() for transmission. - -use axum::{extract::State, Json}; -use tracing::info; -use uuid::Uuid; - -use crate::api::{ - errors::ApiError, - models::{ChanCommand, ChanRequestResponse}, - state::AppState, -}; -use crate::{encoder, framer, wav}; - -// ── Reachability probe ───────────────────────────────────────────────────── - -/// Hits ChanNet's GET /chan/status. Used by broadcast_status to report -/// whether the paired ChanNet node is reachable. -pub async fn check_channet_reachable(channet_url: &str) -> bool { - match reqwest::Client::new() - .get(format!("{channet_url}/chan/status")) - .timeout(std::time::Duration::from_secs(2)) - .send() - .await - { - Ok(r) => r.status().is_success(), - Err(_) => false, - } -} - -// ── ChanNet command client ───────────────────────────────────────────────── - -/// POST a typed ChanCommand to ChanNet's /chan/command endpoint. -/// Returns the raw ZIP bytes from the response body. -pub async fn send_chan_command( - channet_url: &str, - command: &ChanCommand, -) -> Result { - let resp = reqwest::Client::new() - .post(format!("{channet_url}/chan/command")) - .json(command) - .send() - .await - .map_err(|e| ApiError::BroadcasterUnavailable( - format!("ChanNet unreachable at {channet_url}: {e}") - ))?; - - if !resp.status().is_success() { - return Err(ApiError::BroadcasterUnavailable( - format!("ChanNet /chan/command returned HTTP {}", resp.status()), - )); - } - - resp.bytes() - .await - .map_err(|e| ApiError::Internal(format!("reading ChanNet response body: {e}"))) -} - -// ── POST /chan/request ───────────────────────────────────────────────────── - -pub async fn chan_request( - State(state): State, - Json(command): Json, -) -> Result, ApiError> { - info!(?command, channet_url = %state.channet_url, "POST /chan/request"); - - // 1. Fetch ZIP from ChanNet. - let zip_bytes = send_chan_command(&state.channet_url, &command).await?; - let zip_len = zip_bytes.len(); - - // 2. AFSK-encode the ZIP into WAV bytes (CPU-bound; run on blocking thread). - let wav_bytes: Vec = tokio::task::spawn_blocking(move || { - let framed = framer::frame(&zip_bytes, "channet_payload.zip"); - let samples = encoder::encode(&framed); - wav::write_to_bytes(&samples) - }) - .await - .map_err(|e| ApiError::Internal(format!("task panic: {e}")))? - .map_err(ApiError::EncodeFailed)?; - - // 3. Forward the WAV to the external Broadcaster for over-air transmission. - // Reuses the same helper as /broadcast/transmit. - let tx_id = Uuid::new_v4(); - crate::api::broadcast::forward_to_broadcaster( - &state.broadcaster_url, - "channet_payload.zip", - wav_bytes, - tx_id, - ) - .await?; - - info!(%tx_id, zip_bytes = zip_len, "POST /chan/request transmitted successfully"); - - Ok(Json(ChanRequestResponse { - status: "transmitted", - tx_id, - zip_bytes: zip_len, - })) -} -``` - -```bash -cargo check -``` - -**Commit:** `feat: add api/chan.rs — ChanNet client and /chan/request handler` - ---- - ---- - -# STAGE 8 — Router & Server (`src/api/mod.rs`) — CREATE - -**What:** Wires all handlers into two router variants and exposes `run_server`. The `10 MB` body limit is applied as a tower layer on both routers. - -- `full_router(state)` — `/wave/*` + `/broadcast/*` + `/chan/*` (used by `serve` subcommand) -- `gui_router(state)` — `/broadcast/*` + `/chan/*` only (used by GUI background thread) -- `run_server(router, addr)` — binds TCP, starts axum - -Create `src/api/mod.rs`: - -```rust -//! RustWave HTTP API server. -//! -//! full_router() — /wave/* + /broadcast/* + /chan/* (serve subcommand) -//! gui_router() — /broadcast/* + /chan/* (gui subcommand) - -pub mod broadcast; -pub mod chan; -pub mod errors; -pub mod models; -pub mod state; -pub mod wave; - -use axum::{routing::get, routing::post, Router}; -use std::net::SocketAddr; -use tower_http::limit::RequestBodyLimitLayer; -use tracing::info; - -use state::AppState; - -const BODY_LIMIT: usize = 10 * 1024 * 1024; // 10 MB - -pub fn full_router(state: AppState) -> Router { - let wave_routes = Router::new() - .route("/wave/status", get(wave::wave_status)) - .route("/wave/encode", post(wave::wave_encode)) - .route("/wave/decode", post(wave::wave_decode)); - - Router::new() - .merge(wave_routes) - .merge(broadcast_routes()) - .merge(chan_routes()) - .layer(RequestBodyLimitLayer::new(BODY_LIMIT)) - .with_state(state) -} - -pub fn gui_router(state: AppState) -> Router { - Router::new() - .merge(broadcast_routes()) - .merge(chan_routes()) - .layer(RequestBodyLimitLayer::new(BODY_LIMIT)) - .with_state(state) -} - -fn broadcast_routes() -> Router { - Router::new() - .route("/broadcast/status", get(broadcast::broadcast_status)) - .route("/broadcast/transmit", post(broadcast::broadcast_transmit)) - .route("/broadcast/receive", post(broadcast::broadcast_receive)) - .route("/broadcast/incoming", get(broadcast::broadcast_incoming)) -} - -fn chan_routes() -> Router { - Router::new() - .route("/chan/request", post(chan::chan_request)) -} - -pub async fn run_server(router: Router, bind_addr: SocketAddr) -> anyhow::Result<()> { - let listener = tokio::net::TcpListener::bind(bind_addr).await?; - info!(addr = %bind_addr, "RustWave API server listening"); - axum::serve(listener, router).await?; - Ok(()) -} -``` - -```bash -cargo check -``` - -**Commit:** `feat: add api/mod.rs — router builder and run_server` - ---- - ---- - -# STAGE 9 — CLI Entry Point (`src/main.rs`) — EDIT (full replacement) - -**What:** Replace `main.rs` entirely to add the `serve` subcommand, wire logging init, and declare the new `mod api` and `mod logging` modules. All existing `encode`, `decode`, and `gui` subcommand logic is preserved unchanged. - -**Why now:** This is the last structural edit before the two targeted file-level edits. It depends on everything above being in place. - -Replace `src/main.rs` with: - -```rust -mod api; -mod config; -mod decoder; -mod encoder; -mod framer; -mod gui; -mod logging; -mod wav; - -use clap::{Parser, Subcommand}; -use std::{net::SocketAddr, path::PathBuf}; - -#[derive(Parser)] -#[command(name = "rustwave-cli", version, about = "RustWave audio codec", long_about = None)] -struct Cli { - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand)] -enum Command { - /// Launch the drag-and-drop GUI (also starts /broadcast/* API on 127.0.0.1:7071) - Gui, - /// Start the HTTP API server (both /wave/* and /broadcast/* on 127.0.0.1:7071) - Serve { - #[arg(short, long, value_name = "ADDR")] - bind: Option, - }, - /// Encode a file into an AFSK WAV - Encode { - #[arg(short, long, value_name = "FILE")] - input: PathBuf, - #[arg(short, long, value_name = "FILE")] - output: PathBuf, - }, - /// Decode an AFSK WAV — restores the original filename automatically - Decode { - #[arg(short, long, value_name = "FILE")] - input: PathBuf, - #[arg(short, long, value_name = "FILE")] - output: Option, - }, -} - -fn main() { - let _log_guard = logging::init(); - if let Err(e) = run() { - eprintln!("error: {e}"); - std::process::exit(1); - } -} - -fn run() -> Result<(), String> { - let cli = Cli::parse(); - - match cli.command { - Command::Gui => { - gui::run().map_err(|e| format!("GUI error: {e}"))?; - } - - Command::Serve { bind } => { - let addr = bind - .or_else(|| std::env::var("RUSTWAVE_BIND").ok().and_then(|s| s.parse().ok())) - .unwrap_or_else(|| "127.0.0.1:7071".parse().unwrap()); - - let rt = tokio::runtime::Runtime::new() - .map_err(|e| format!("failed to build Tokio runtime: {e}"))?; - - rt.block_on(async move { - let state = api::state::AppState::new(true); - let router = api::full_router(state); - api::run_server(router, addr) - .await - .map_err(|e| format!("server error: {e}")) - })?; - } - - Command::Encode { input, output } => { - let data = std::fs::read(&input) - .map_err(|e| format!("cannot read '{}': {e}", input.display()))?; - let filename = input.file_name().unwrap_or_default().to_string_lossy().into_owned(); - let framed = framer::frame(&data, &filename); - let samples = encoder::encode(&framed); - wav::write(&output, &samples) - .map_err(|e| format!("cannot write '{}': {e}", output.display()))?; - #[allow(clippy::cast_precision_loss)] - let duration = samples.len() as f64 / f64::from(config::SAMPLE_RATE); - eprintln!("encoded '{}' ({} byte{}) -> {} ({duration:.2} s)", - filename, data.len(), plural(data.len()), output.display()); - } - - Command::Decode { input, output } => { - let samples = wav::read(&input) - .map_err(|e| format!("cannot read '{}': {e}", input.display()))?; - let decoded = decoder::decode(&samples).map_err(|e| format!("decode failed: {e}"))?; - let out_path = output.unwrap_or_else(|| { - input.parent().unwrap_or_else(|| std::path::Path::new(".")).join(&decoded.filename) - }); - std::fs::write(&out_path, &decoded.data) - .map_err(|e| format!("cannot write '{}': {e}", out_path.display()))?; - eprintln!("decoded {} byte{} -> '{}' (original filename: '{}')", - decoded.data.len(), plural(decoded.data.len()), out_path.display(), decoded.filename); - } - } - - Ok(()) -} - -const fn plural(n: usize) -> &'static str { - if n == 1 { "" } else { "s" } -} -``` - -```bash -cargo check -cargo test -./target/debug/rustwave-cli --help -``` - -**Commit:** `feat: add serve subcommand and wire logging in main.rs` - ---- - ---- - -# STAGE 10 — GUI API Thread (`src/gui.rs`) — EDIT (targeted) - -**What:** Spawn the `/broadcast/*` API server on a background OS thread before launching eframe. **No other changes to `gui.rs`.** All existing drag-and-drop, progress bar, and encode/decode logic is untouched. - -**How:** Find the existing `pub fn run() -> eframe::Result<()>` function at the bottom of `gui.rs` and replace it with the version below. - -```rust -pub fn run() -> eframe::Result<()> { - // Spawn the /broadcast/* API server on a background OS thread. - std::thread::spawn(|| { - let addr: std::net::SocketAddr = std::env::var("RUSTWAVE_BIND") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or_else(|| "127.0.0.1:7071".parse().unwrap()); - - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("failed to build Tokio runtime for GUI API server"); - - rt.block_on(async move { - let state = crate::api::state::AppState::new(false); - let router = crate::api::gui_router(state); - if let Err(e) = crate::api::run_server(router, addr).await { - tracing::error!("GUI API server error: {e}"); - } - }); - }); - - tracing::info!("GUI mode: /broadcast/* API started on 127.0.0.1:7071"); - - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_inner_size([480.0, 340.0]) - .with_min_inner_size([360.0, 260.0]) - .with_title("RustWave") - .with_drag_and_drop(true), - ..Default::default() - }; - - eframe::run_native( - "RustWave", - options, - Box::new(|cc| Ok(Box::new(AfskGui::new(cc)) as Box)), - ) -} -``` - -```bash -cargo check -cargo clippy -``` - -**Commit:** `feat: spawn broadcast API server in GUI mode` - ---- - ---- - -# STAGE 11 — In-Memory WAV I/O (`src/wav.rs`) — EDIT (append) - -**What:** Add `write_to_bytes` and `read_from_bytes` to `wav.rs`. These are the in-memory counterparts to the existing file-based `write`/`read` functions, used by every API handler to avoid temp files. Also adds a `memory_round_trip` unit test. - -**How:** Append the two functions **before** the existing `#[cfg(test)]` block, then add the test **inside** the existing `#[cfg(test)]` block. - -Append before `#[cfg(test)]`: - -```rust -// ── In-memory variants used by the HTTP API ────────────────────────────── - -pub fn write_to_bytes(samples: &[f64]) -> Result, String> { - use std::io::Cursor; - let spec = hound::WavSpec { - channels: 1, - sample_rate: SAMPLE_RATE, - bits_per_sample: 16, - sample_format: hound::SampleFormat::Int, - }; - - let mut buf: Vec = Vec::new(); - let cursor = Cursor::new(&mut buf); - let mut writer = hound::WavWriter::new(cursor, spec).map_err(|e| e.to_string())?; - - for &s in samples { - #[allow(clippy::cast_possible_truncation)] - let v = (s.clamp(-1.0, 1.0) * 32_767.0) as i16; - writer.write_sample(v).map_err(|e| e.to_string())?; - } - - writer.finalize().map_err(|e| e.to_string())?; - Ok(buf) -} - -pub fn read_from_bytes(data: &[u8]) -> Result, String> { - use std::io::Cursor; - let cursor = Cursor::new(data); - let mut reader = hound::WavReader::new(cursor).map_err(|e| e.to_string())?; - let spec = reader.spec(); - - match (spec.bits_per_sample, spec.sample_format) { - (16, hound::SampleFormat::Int) => { - let channels = usize::from(spec.channels); - if channels == 0 { - return Err("invalid WAV: 0 channels".into()); - } - reader - .samples::() - .step_by(channels) - .map(|s| s.map(|v| f64::from(v) / 32_768.0).map_err(|e| e.to_string())) - .collect() - } - (bits, fmt) => Err(format!( - "unsupported WAV format: {bits}-bit {fmt:?} (rustwave-cli expects 16-bit integer PCM)" - )), - } -} -``` - -Add inside the existing `#[cfg(test)]` block: - -```rust -#[test] -fn memory_round_trip() -> Result<(), String> { - use std::f64::consts::TAU; - #[allow(clippy::cast_precision_loss)] - let original: Vec = (0..4_410_i32) - .map(|i| 0.5 * (TAU * 440.0 * f64::from(i) / 44_100.0).sin()) - .collect(); - let bytes = write_to_bytes(&original)?; - let recovered = read_from_bytes(&bytes)?; - assert_eq!(original.len(), recovered.len()); - for (a, b) in original.iter().zip(recovered.iter()) { - assert!((a - b).abs() < 5e-5, "quantisation error: {a} vs {b}"); - } - Ok(()) -} -``` - -```bash -cargo check -cargo test -``` - -**Commit:** `feat: add wav::write_to_bytes and read_from_bytes for API use` - ---- - ---- - -# STAGE 12 — Queue Unit Test (`src/api/tests.rs`) — CREATE - -**What:** Async unit test for `AppState`'s queue enqueue/dequeue round-trip. Validates that `queue_depth`, `enqueue`, and `dequeue` behave correctly under a `#[tokio::test]` runtime. - -Create `src/api/tests.rs`: - -```rust -#[cfg(test)] -mod tests { - use crate::api::state::AppState; - - #[tokio::test] - async fn queue_enqueue_dequeue() { - use crate::api::state::QueuedFile; - use bytes::Bytes; - use uuid::Uuid; - - let state = AppState::new(false); - assert_eq!(state.queue_depth().await, 0); - - state.enqueue(QueuedFile { - queued_id: Uuid::new_v4(), - bytes: Bytes::from_static(b"hello"), - }).await; - - assert_eq!(state.queue_depth().await, 1); - let file = state.dequeue().await.unwrap(); - assert_eq!(file.bytes.as_ref(), b"hello"); - assert!(state.dequeue().await.is_none()); - } -} -``` - -Also add `pub mod tests;` to the bottom of `src/api/mod.rs`. - -```bash -cargo test -``` - -**Commit:** `test: add api/tests.rs queue round-trip test` - ---- - ---- - -# STAGE 13 — Final Verification - -Run the full suite before tagging: - -```bash -cargo deny check -cargo clippy -- -D warnings -cargo test -cargo build --release -./target/release/rustwave-cli --help -git tag v0.2.0-api -``` - -Expected: all green, binary prints help text showing `gui`, `serve`, `encode`, `decode` subcommands. - ---- - -## File Change Summary - -| File | Action | Stage | -|---|---|---| -| `Cargo.toml` | EDIT | 1 | -| `src/logging.rs` | CREATE | 2 | -| `src/api/models.rs` | CREATE | 3 | -| `src/api/errors.rs` | CREATE | 4 | -| `src/api/state.rs` | CREATE | 5 | -| `src/api/wave.rs` | CREATE | 6 | -| `src/api/broadcast.rs` | CREATE | 7 | -| `src/api/chan.rs` | CREATE | 7.5 | -| `src/api/mod.rs` | CREATE | 8 | -| `src/main.rs` | EDIT (full replace) | 9 | -| `src/gui.rs` | EDIT (`run()` only) | 10 | -| `src/wav.rs` | EDIT (append) | 11 | -| `src/api/tests.rs` | CREATE | 12 | diff --git a/src/api/broadcast.rs b/src/api/broadcast.rs index 87d88a1..a0d8b2d 100644 --- a/src/api/broadcast.rs +++ b/src/api/broadcast.rs @@ -46,15 +46,12 @@ pub async fn broadcast_status(State(state): State) -> Json bool { - match reqwest::Client::new() + reqwest::Client::new() .get(url) .timeout(std::time::Duration::from_secs(2)) .send() .await - { - Ok(r) => r.status().is_success(), - Err(_) => false, - } + .is_ok_and(|r| r.status().is_success()) } // ── POST /broadcast/transmit ─────────────────────────────────────────────── @@ -87,7 +84,11 @@ pub async fn broadcast_transmit( forward_to_broadcaster(&state.broadcaster_url, &filename, wav_bytes, tx_id).await?; - Ok(Json(TransmitResponse { status: "ok", tx_id, wav_bytes: wav_size })) + Ok(Json(TransmitResponse { + status: "ok", + tx_id, + wav_bytes: wav_size, + })) } pub async fn forward_to_broadcaster( @@ -114,13 +115,16 @@ pub async fn forward_to_broadcaster( .multipart(form) .send() .await - .map_err(|e| ApiError::BroadcasterUnavailable( - format!("could not reach Broadcaster at {broadcaster_url}: {e}") - ))?; + .map_err(|e| { + ApiError::BroadcasterUnavailable(format!( + "could not reach Broadcaster at {broadcaster_url}: {e}" + )) + })?; if !resp.status().is_success() { return Err(ApiError::BroadcasterUnavailable(format!( - "Broadcaster returned HTTP {} for tx_id {tx_id}", resp.status() + "Broadcaster returned HTTP {} for tx_id {tx_id}", + resp.status() ))); } @@ -136,7 +140,10 @@ pub async fn broadcast_receive( ) -> Result, ApiError> { let (_filename, wav_bytes) = extract_file_field(&mut multipart).await?; - info!(wav_bytes = wav_bytes.len(), "POST /broadcast/receive decoding WAV"); + info!( + wav_bytes = wav_bytes.len(), + "POST /broadcast/receive decoding WAV" + ); let decoded = tokio::task::spawn_blocking(move || { let samples = wav::read_from_bytes(&wav_bytes)?; @@ -156,58 +163,67 @@ pub async fn broadcast_receive( "POST /broadcast/receive queuing decoded file" ); - state.enqueue(QueuedFile { queued_id, bytes: Bytes::from(decoded.data) }).await; - - Ok(Json(ReceiveResponse { status: "ok", queued_id, decoded_bytes: decoded_size })) + state + .enqueue(QueuedFile { + queued_id, + bytes: Bytes::from(decoded.data), + }) + .await; + + Ok(Json(ReceiveResponse { + status: "ok", + queued_id, + decoded_bytes: decoded_size, + })) } // ── GET /broadcast/incoming ──────────────────────────────────────────────── pub async fn broadcast_incoming(State(state): State) -> Response { - match state.dequeue().await { - Some(file) => { - info!(queued_id = %file.queued_id, bytes = file.bytes.len(), - "GET /broadcast/incoming dequeuing file"); - ( - StatusCode::OK, - [ - (header::CONTENT_TYPE, "application/octet-stream"), - (header::CONTENT_DISPOSITION, "attachment; filename=\"snapshot.zip\""), - ], - file.bytes, - ) - .into_response() - } - None => { - debug!("GET /broadcast/incoming queue is empty"); - (StatusCode::OK, Json(QueueEmptyResponse { status: "empty" })).into_response() - } + if let Some(file) = state.dequeue().await { + info!(queued_id = %file.queued_id, bytes = file.bytes.len(), + "GET /broadcast/incoming dequeuing file"); + ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, "application/octet-stream"), + ( + header::CONTENT_DISPOSITION, + "attachment; filename=\"snapshot.zip\"", + ), + ], + file.bytes, + ) + .into_response() + } else { + debug!("GET /broadcast/incoming queue is empty"); + (StatusCode::OK, Json(QueueEmptyResponse { status: "empty" })).into_response() } } // ── Shared helper ────────────────────────────────────────────────────────── -async fn extract_file_field( - multipart: &mut Multipart, -) -> Result<(String, Vec), ApiError> { - while let Some(field) = multipart +async fn extract_file_field(multipart: &mut Multipart) -> Result<(String, Vec), ApiError> { + let Some(field) = multipart .next_field() .await .map_err(|e| ApiError::BadRequest(format!("multipart error: {e}")))? - { - let filename = field.file_name().unwrap_or("upload").to_string(); - let data = field - .bytes() - .await - .map_err(|e| ApiError::BadRequest(format!("could not read field bytes: {e}")))?; - - if data.is_empty() { - warn!(filename = %filename, "received empty file field"); - return Err(ApiError::BadRequest("file field is empty".into())); - } + else { + return Err(ApiError::BadRequest( + "no file field found in multipart body".into(), + )); + }; + + let filename = field.file_name().unwrap_or("upload").to_string(); + let data = field + .bytes() + .await + .map_err(|e| ApiError::BadRequest(format!("could not read field bytes: {e}")))?; - return Ok((filename, data.to_vec())); + if data.is_empty() { + warn!(filename = %filename, "received empty file field"); + return Err(ApiError::BadRequest("file field is empty".into())); } - Err(ApiError::BadRequest("no file field found in multipart body".into())) + Ok((filename, data.to_vec())) } diff --git a/src/api/chan.rs b/src/api/chan.rs index 33b21e6..d140891 100644 --- a/src/api/chan.rs +++ b/src/api/chan.rs @@ -1,13 +1,13 @@ -//! ChanNet HTTP client and /chan/request proxy handler. +//! `ChanNet` HTTP client and /chan/request proxy handler. //! -//! ChanNet already calls RustWave on: +//! `ChanNet` already calls `RustWave` on: //! POST /broadcast/transmit — pushes a ZIP snapshot for AFSK encoding & over-air transmission //! GET /broadcast/incoming — pulls decoded ZIP snapshots that arrived over radio //! //! This module adds the outbound direction: -//! POST /chan/request — operator sends a typed ChanCommand; RustWave forwards it to -//! ChanNet's /chan/command, receives the ZIP response, AFSK-encodes -//! it into WAV, and calls forward_to_broadcaster() for transmission. +//! POST /chan/request — operator sends a typed `ChanCommand`; `RustWave` forwards it to +//! `ChanNet`'s /chan/command, receives the ZIP response, AFSK-encodes +//! it into WAV, and calls `forward_to_broadcaster()` for transmission. use axum::{extract::State, Json}; use tracing::info; @@ -22,23 +22,20 @@ use crate::{encoder, framer, wav}; // ── Reachability probe ───────────────────────────────────────────────────── -/// Hits ChanNet's GET /chan/status. Used by broadcast_status to report -/// whether the paired ChanNet node is reachable. +/// Hits `ChanNet`'s GET /chan/status. Used by `broadcast_status` to report +/// whether the paired `ChanNet` node is reachable. pub async fn check_channet_reachable(channet_url: &str) -> bool { - match reqwest::Client::new() + reqwest::Client::new() .get(format!("{channet_url}/chan/status")) .timeout(std::time::Duration::from_secs(2)) .send() .await - { - Ok(r) => r.status().is_success(), - Err(_) => false, - } + .is_ok_and(|r| r.status().is_success()) } // ── ChanNet command client ───────────────────────────────────────────────── -/// POST a typed ChanCommand to ChanNet's /chan/command endpoint. +/// POST a typed `ChanCommand` to `ChanNet`'s /chan/command endpoint. /// Returns the raw ZIP bytes from the response body. pub async fn send_chan_command( channet_url: &str, @@ -49,14 +46,15 @@ pub async fn send_chan_command( .json(command) .send() .await - .map_err(|e| ApiError::BroadcasterUnavailable( - format!("ChanNet unreachable at {channet_url}: {e}") - ))?; + .map_err(|e| { + ApiError::BroadcasterUnavailable(format!("ChanNet unreachable at {channet_url}: {e}")) + })?; if !resp.status().is_success() { - return Err(ApiError::BroadcasterUnavailable( - format!("ChanNet /chan/command returned HTTP {}", resp.status()), - )); + return Err(ApiError::BroadcasterUnavailable(format!( + "ChanNet /chan/command returned HTTP {}", + resp.status() + ))); } resp.bytes() diff --git a/src/api/errors.rs b/src/api/errors.rs index 2d16687..181ff7c 100644 --- a/src/api/errors.rs +++ b/src/api/errors.rs @@ -1,19 +1,18 @@ -//! API error type for RustWave. +//! API error type for `RustWave`. //! //! Every handler returns `Result<_, ApiError>`. axum automatically calls //! `IntoResponse` on the error path. +use crate::api::models::{ErrorDetail, ErrorEnvelope}; use axum::{ http::StatusCode, response::{IntoResponse, Response}, Json, }; -use crate::api::models::{ErrorDetail, ErrorEnvelope}; #[derive(Debug)] pub enum ApiError { BadRequest(String), - PayloadTooLarge, EncodeFailed(String), DecodeFailed(String), BroadcasterUnavailable(String), @@ -21,25 +20,22 @@ pub enum ApiError { } impl ApiError { - fn code(&self) -> &'static str { + pub const fn code(&self) -> &'static str { match self { - Self::BadRequest(_) => "BAD_REQUEST", - Self::PayloadTooLarge => "PAYLOAD_TOO_LARGE", - Self::EncodeFailed(_) => "ENCODE_FAILED", - Self::DecodeFailed(_) => "DECODE_FAILED", + Self::BadRequest(_) => "BAD_REQUEST", + Self::EncodeFailed(_) => "ENCODE_FAILED", + Self::DecodeFailed(_) => "DECODE_FAILED", Self::BroadcasterUnavailable(_) => "BROADCASTER_UNAVAILABLE", - Self::Internal(_) => "INTERNAL_ERROR", + Self::Internal(_) => "INTERNAL_ERROR", } } - fn status_code(&self) -> StatusCode { + pub const fn status_code(&self) -> StatusCode { match self { - Self::BadRequest(_) => StatusCode::BAD_REQUEST, - Self::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE, - Self::EncodeFailed(_) => StatusCode::UNPROCESSABLE_ENTITY, - Self::DecodeFailed(_) => StatusCode::UNPROCESSABLE_ENTITY, + Self::BadRequest(_) => StatusCode::BAD_REQUEST, + Self::EncodeFailed(_) | Self::DecodeFailed(_) => StatusCode::UNPROCESSABLE_ENTITY, Self::BroadcasterUnavailable(_) => StatusCode::BAD_GATEWAY, - Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, } } } @@ -48,12 +44,11 @@ impl IntoResponse for ApiError { fn into_response(self) -> Response { let status = self.status_code(); let message = match &self { - Self::BadRequest(m) => m.clone(), - Self::PayloadTooLarge => "Request body exceeds the 10 MB limit.".into(), - Self::EncodeFailed(m) => m.clone(), - Self::DecodeFailed(m) => m.clone(), - Self::BroadcasterUnavailable(m) => m.clone(), - Self::Internal(m) => m.clone(), + Self::BadRequest(m) + | Self::EncodeFailed(m) + | Self::DecodeFailed(m) + | Self::BroadcasterUnavailable(m) + | Self::Internal(m) => m.clone(), }; tracing::error!( diff --git a/src/api/mod.rs b/src/api/mod.rs index b56ecff..986db9c 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,7 +1,7 @@ -//! RustWave HTTP API server. +//! `RustWave` HTTP API server. //! -//! full_router() — /wave/* + /broadcast/* + /chan/* (serve subcommand) -//! gui_router() — /broadcast/* + /chan/* (gui subcommand) +//! `full_router()` — /wave/* + /broadcast/* + /chan/* (serve subcommand) +//! `gui_router()` — /broadcast/* + /chan/* (gui subcommand) pub mod broadcast; pub mod chan; @@ -43,15 +43,14 @@ pub fn gui_router(state: AppState) -> Router { fn broadcast_routes() -> Router { Router::new() - .route("/broadcast/status", get(broadcast::broadcast_status)) + .route("/broadcast/status", get(broadcast::broadcast_status)) .route("/broadcast/transmit", post(broadcast::broadcast_transmit)) - .route("/broadcast/receive", post(broadcast::broadcast_receive)) + .route("/broadcast/receive", post(broadcast::broadcast_receive)) .route("/broadcast/incoming", get(broadcast::broadcast_incoming)) } fn chan_routes() -> Router { - Router::new() - .route("/chan/request", post(chan::chan_request)) + Router::new().route("/chan/request", post(chan::chan_request)) } pub async fn run_server(router: Router, bind_addr: SocketAddr) -> anyhow::Result<()> { @@ -63,4 +62,3 @@ pub async fn run_server(router: Router, bind_addr: SocketAddr) -> anyhow::Result #[cfg(test)] pub mod tests; - diff --git a/src/api/models.rs b/src/api/models.rs index 43e2504..d1ad9c1 100644 --- a/src/api/models.rs +++ b/src/api/models.rs @@ -1,4 +1,4 @@ -//! JSON request and response types for the RustWave API. +//! JSON request and response types for the `RustWave` API. use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -61,14 +61,14 @@ pub enum ChanCommand { /// All archived threads + posts for a single board. Always a full export. ArchiveExport { board: String }, /// Entire database — all boards, threads, archives, posts. Use for initial - /// sync or recovery only; RustChan logs a warning when this is received. + /// sync or recovery only; `RustChan` logs a warning when this is received. ForceRefresh, /// Post a new reply to an existing thread (the only write command). ReplyPush { - board: String, + board: String, thread_id: u64, - author: String, - content: String, + author: String, + content: String, timestamp: u64, }, } @@ -76,8 +76,8 @@ pub enum ChanCommand { /// Returned by POST /chan/request on success. #[derive(Serialize)] pub struct ChanRequestResponse { - pub status: &'static str, // "transmitted" - pub tx_id: uuid::Uuid, + pub status: &'static str, // "transmitted" + pub tx_id: uuid::Uuid, pub zip_bytes: usize, } diff --git a/src/api/state.rs b/src/api/state.rs index 7a46a40..189b71e 100644 --- a/src/api/state.rs +++ b/src/api/state.rs @@ -1,7 +1,7 @@ -//! Shared state for the RustWave API server. +//! Shared state for the `RustWave` API server. -use std::{collections::VecDeque, sync::Arc}; use bytes::Bytes; +use std::{collections::VecDeque, sync::Arc}; use tokio::sync::Mutex; use uuid::Uuid; @@ -17,6 +17,7 @@ pub type IncomingQueue = Arc>>; pub struct AppState { pub broadcaster_url: String, pub channet_url: String, + #[allow(dead_code)] pub wave_routes_enabled: bool, pub incoming_queue: IncomingQueue, } diff --git a/src/api/tests.rs b/src/api/tests.rs new file mode 100644 index 0000000..001ab02 --- /dev/null +++ b/src/api/tests.rs @@ -0,0 +1,50 @@ +//! Integration tests for the `RustWave` API layer. + +use crate::api::state::{AppState, QueuedFile}; +use bytes::Bytes; +use uuid::Uuid; + +#[tokio::test] +async fn queue_enqueue_dequeue_roundtrip() { + let state = AppState::new(false); + assert_eq!(state.queue_depth().await, 0); + + state + .enqueue(QueuedFile { + queued_id: Uuid::new_v4(), + bytes: Bytes::from_static(b"api-test"), + }) + .await; + + assert_eq!(state.queue_depth().await, 1); + assert!(state.dequeue().await.is_some()); + assert!(state.dequeue().await.is_none()); +} + +#[tokio::test] +async fn queue_preserves_fifo_order() { + let state = AppState::new(true); + + for i in 0u8..3 { + state + .enqueue(QueuedFile { + queued_id: Uuid::new_v4(), + bytes: Bytes::from(vec![i]), + }) + .await; + } + + assert_eq!(state.queue_depth().await, 3); + for expected in 0u8..3 { + let dequeued = state.dequeue().await; + assert!(dequeued.is_some(), "queue should have an item"); + if let Some(file) = dequeued { + assert_eq!( + file.bytes.first().copied(), + Some(expected), + "bytes non-empty" + ); + } + } + assert_eq!(state.queue_depth().await, 0); +} diff --git a/src/api/wave.rs b/src/api/wave.rs index bf7e55b..fc40035 100644 --- a/src/api/wave.rs +++ b/src/api/wave.rs @@ -55,7 +55,10 @@ pub async fn wave_encode(mut multipart: Multipart) -> Result Ok(( [ (header::CONTENT_TYPE, "audio/wav"), - (header::CONTENT_DISPOSITION, &format!("attachment; filename=\"{out_name}\"")), + ( + header::CONTENT_DISPOSITION, + &format!("attachment; filename=\"{out_name}\""), + ), ], wav_bytes, ) @@ -87,7 +90,10 @@ pub async fn wave_decode(mut multipart: Multipart) -> Result Ok(( [ (header::CONTENT_TYPE, "application/octet-stream"), - (header::CONTENT_DISPOSITION, &format!("attachment; filename=\"{}\"", result.filename)), + ( + header::CONTENT_DISPOSITION, + &format!("attachment; filename=\"{}\"", result.filename), + ), ], result.data, ) @@ -96,27 +102,27 @@ pub async fn wave_decode(mut multipart: Multipart) -> Result // ── Shared helper ────────────────────────────────────────────────────────── -async fn extract_file_field( - multipart: &mut Multipart, -) -> Result<(String, Vec), ApiError> { - while let Some(field) = multipart +async fn extract_file_field(multipart: &mut Multipart) -> Result<(String, Vec), ApiError> { + let Some(field) = multipart .next_field() .await .map_err(|e| ApiError::BadRequest(format!("multipart error: {e}")))? - { - let filename = field.file_name().unwrap_or("upload").to_string(); - let data = field - .bytes() - .await - .map_err(|e| ApiError::BadRequest(format!("could not read field bytes: {e}")))?; - - if data.is_empty() { - warn!(filename = %filename, "received empty file field"); - return Err(ApiError::BadRequest("file field is empty".into())); - } - - return Ok((filename, data.to_vec())); + else { + return Err(ApiError::BadRequest( + "no file field found in multipart body".into(), + )); + }; + + let filename = field.file_name().unwrap_or("upload").to_string(); + let data = field + .bytes() + .await + .map_err(|e| ApiError::BadRequest(format!("could not read field bytes: {e}")))?; + + if data.is_empty() { + warn!(filename = %filename, "received empty file field"); + return Err(ApiError::BadRequest("file field is empty".into())); } - Err(ApiError::BadRequest("no file field found in multipart body".into())) + Ok((filename, data.to_vec())) } diff --git a/src/gui.rs b/src/gui.rs index e5e25f1..1445172 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -456,12 +456,18 @@ pub fn run() -> eframe::Result<()> { let addr: std::net::SocketAddr = std::env::var("RUSTWAVE_BIND") .ok() .and_then(|s| s.parse().ok()) - .unwrap_or_else(|| "127.0.0.1:7071".parse().unwrap()); + .unwrap_or_else(|| std::net::SocketAddr::from(([127, 0, 0, 1], 7071))); - let rt = tokio::runtime::Builder::new_current_thread() + let rt = match tokio::runtime::Builder::new_current_thread() .enable_all() .build() - .expect("failed to build Tokio runtime for GUI API server"); + { + Ok(rt) => rt, + Err(e) => { + tracing::error!("failed to build Tokio runtime for GUI API server: {e}"); + return; + } + }; rt.block_on(async move { let state = crate::api::state::AppState::new(false); diff --git a/src/logging.rs b/src/logging.rs index 9536373..2be3e8a 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -1,4 +1,4 @@ -//! Logging initialisation for RustWave. +//! Logging initialisation for `RustWave`. //! //! Call `logging::init()` once at the start of `main()`. //! @@ -7,13 +7,11 @@ //! - rustwave.log (file): DEBUG and above, JSON format, rolling daily //! //! The log file is written next to the binary. -//! Set RUSTWAVE_LOG=debug to see debug output on stderr too. +//! Set `RUSTWAVE_LOG`=debug to see debug output on stderr too. use std::path::PathBuf; use tracing_appender::rolling; -use tracing_subscriber::{ - fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer, -}; +use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer}; /// Initialise logging. Must be called once before any tracing macros are used. /// @@ -31,12 +29,10 @@ pub fn init() -> tracing_appender::non_blocking::WorkerGuard { let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); // stderr layer — human readable, INFO+ by default, respects RUSTWAVE_LOG - let stderr_filter = EnvFilter::try_from_env("RUSTWAVE_LOG") - .unwrap_or_else(|_| EnvFilter::new("info")); + let stderr_filter = + EnvFilter::try_from_env("RUSTWAVE_LOG").unwrap_or_else(|_| EnvFilter::new("info")); - let stderr_layer = fmt::layer() - .with_target(false) - .with_filter(stderr_filter); + let stderr_layer = fmt::layer().with_target(false).with_filter(stderr_filter); // file layer — JSON, DEBUG+ let file_layer = fmt::layer() diff --git a/src/main.rs b/src/main.rs index ee1796f..01a1191 100644 --- a/src/main.rs +++ b/src/main.rs @@ -60,8 +60,12 @@ fn run() -> Result<(), String> { Command::Serve { bind } => { let addr = bind - .or_else(|| std::env::var("RUSTWAVE_BIND").ok().and_then(|s| s.parse().ok())) - .unwrap_or_else(|| "127.0.0.1:7071".parse().unwrap()); + .or_else(|| { + std::env::var("RUSTWAVE_BIND") + .ok() + .and_then(|s| s.parse().ok()) + }) + .unwrap_or_else(|| std::net::SocketAddr::from(([127, 0, 0, 1], 7071))); let rt = tokio::runtime::Runtime::new() .map_err(|e| format!("failed to build Tokio runtime: {e}"))?; @@ -78,28 +82,45 @@ fn run() -> Result<(), String> { Command::Encode { input, output } => { let data = std::fs::read(&input) .map_err(|e| format!("cannot read '{}': {e}", input.display()))?; - let filename = input.file_name().unwrap_or_default().to_string_lossy().into_owned(); + let filename = input + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); let framed = framer::frame(&data, &filename); let samples = encoder::encode(&framed); wav::write(&output, &samples) .map_err(|e| format!("cannot write '{}': {e}", output.display()))?; #[allow(clippy::cast_precision_loss)] let duration = samples.len() as f64 / f64::from(config::SAMPLE_RATE); - eprintln!("encoded '{}' ({} byte{}) -> {} ({duration:.2} s)", - filename, data.len(), plural(data.len()), output.display()); + eprintln!( + "encoded '{}' ({} byte{}) -> {} ({duration:.2} s)", + filename, + data.len(), + plural(data.len()), + output.display() + ); } Command::Decode { input, output } => { - let samples = wav::read(&input) - .map_err(|e| format!("cannot read '{}': {e}", input.display()))?; + let samples = + wav::read(&input).map_err(|e| format!("cannot read '{}': {e}", input.display()))?; let decoded = decoder::decode(&samples).map_err(|e| format!("decode failed: {e}"))?; let out_path = output.unwrap_or_else(|| { - input.parent().unwrap_or_else(|| std::path::Path::new(".")).join(&decoded.filename) + input + .parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .join(&decoded.filename) }); std::fs::write(&out_path, &decoded.data) .map_err(|e| format!("cannot write '{}': {e}", out_path.display()))?; - eprintln!("decoded {} byte{} -> '{}' (original filename: '{}')", - decoded.data.len(), plural(decoded.data.len()), out_path.display(), decoded.filename); + eprintln!( + "decoded {} byte{} -> '{}' (original filename: '{}')", + decoded.data.len(), + plural(decoded.data.len()), + out_path.display(), + decoded.filename + ); } } @@ -107,5 +128,9 @@ fn run() -> Result<(), String> { } const fn plural(n: usize) -> &'static str { - if n == 1 { "" } else { "s" } + if n == 1 { + "" + } else { + "s" + } } diff --git a/src/wav.rs b/src/wav.rs index bfb31e0..abd63b4 100644 --- a/src/wav.rs +++ b/src/wav.rs @@ -93,7 +93,10 @@ pub fn read_from_bytes(data: &[u8]) -> Result, String> { reader .samples::() .step_by(channels) - .map(|s| s.map(|v| f64::from(v) / 32_768.0).map_err(|e| e.to_string())) + .map(|s| { + s.map(|v| f64::from(v) / 32_768.0) + .map_err(|e| e.to_string()) + }) .collect() } (bits, fmt) => Err(format!(