commit 3241f3c961f53f854ce11cecee9a34e5a3ced306 Author: Jon Seager Date: Mon Nov 24 11:22:17 2025 +0000 feat: bootstrap brewlog platform diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b9d992 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +target/ +brewlog.db +TODO.md +SPEC.md diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6528914 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3253 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "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 = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "askama" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" +dependencies = [ + "askama_derive", + "askama_escape", + "humansize", + "num-traits", + "percent-encoding", +] + +[[package]] +name = "askama_derive" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" +dependencies = [ + "askama_parser", + "basic-toml", + "mime", + "mime_guess", + "proc-macro2", + "quote", + "serde", + "syn 2.0.110", +] + +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + +[[package]] +name = "askama_parser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" +dependencies = [ + "nom", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "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 = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-id" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28af93b9e274f9109b06714ef4391d9e159ad849cba25fbd184ef7e281650263" +dependencies = [ + "rand 0.8.5", + "rand_core 0.6.4", + "rand_pcg", +] + +[[package]] +name = "brewlog" +version = "0.1.0" +dependencies = [ + "anyhow", + "askama", + "async-trait", + "axum", + "block-id", + "chrono", + "clap", + "once_cell", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "sqlx", + "thiserror 1.0.69", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8120877db0e5c011242f96806ce3c94e0737ab8108532a76a3300a01db2ab8" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02576b399397b659c26064fbc92a75fede9d18ffd5f80ca1cd74ddab167016e1" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "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 = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + +[[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", + "webpki-roots", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown 0.16.0", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags", + "libc", + "redox_syscall", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[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 = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "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-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_pcg" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59cad018caf63deb318e5a4586d99a24424a364f40f1e5778c29aca23f4fc73e" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.12.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a0376c50d0358279d9d643e4bf7b7be212f1f4ff1da9070a7b54d22ef75c88" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[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_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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlformat" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790" +dependencies = [ + "nom", + "unicode_categories", +] + +[[package]] +name = "sqlx" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9a2ccff1a000a5a59cd33da541d9f2fdcd9e6e8229cc200565942bff36d0aaa" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ba59a9342a3d9bab6c56c118be528b27c9b60e490080e9711a04dccac83ef6" +dependencies = [ + "ahash", + "atoi", + "byteorder", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-channel", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashlink", + "hex", + "indexmap", + "log", + "memchr", + "once_cell", + "paste", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlformat", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea40e2345eb2faa9e1e5e326db8c34711317d2b5e08d0d5741619048a803127" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 1.0.109", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5833ef53aaa16d860e92123292f1f6a3d53c34ba8b1969f152ef1a7bb803f3c8" +dependencies = [ + "dotenvy", + "either", + "heck 0.4.1", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 1.0.109", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418" +dependencies = [ + "atoi", + "base64 0.21.7", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 1.0.69", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e" +dependencies = [ + "atoi", + "base64 0.21.7", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 1.0.69", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "tracing", + "url", + "urlencoding", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "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.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[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-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.110", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..cce23e3 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "brewlog" +version = "0.1.0" +edition = "2024" + +[features] +default = ["sqlite"] +sqlite = ["sqlx/sqlite"] +postgres = ["sqlx/postgres"] + +[dependencies] +anyhow = "1.0" +async-trait = "0.1" +axum = { version = "0.7", features = ["macros"] } +askama = "0.12" +block-id = "0.2.1" +chrono = { version = "0.4", features = ["serde", "clock"] } +clap = { version = "4.5", features = ["derive", "env"] } +reqwest = { version = "0.12", features = ["json", "rustls-tls"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +once_cell = "1.19" +rand = "0.8" +sqlx = { version = "0.7", default-features = false, features = [ + "runtime-tokio", + "macros", + "chrono", + "any", + "migrate", +] } +thiserror = "1.0" +tokio = { version = "1.38", features = ["rt-multi-thread", "macros", "signal"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba98655 --- /dev/null +++ b/flake.lock @@ -0,0 +1,62 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1763283776, + "narHash": "sha256-Y7TDFPK4GlqrKrivOcsHG8xSGqQx3A6c+i7novT85Uk=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "50a96edd8d0db6cc8db57dab6bb6d6ee1f3dc49a", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1744536153, + "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1763433504, + "narHash": "sha256-cVid5UNpk88sPYHkLAA5aZEHOFQXSB/2L1vl18Aq7IM=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "42ce16c6d8318a654d53f047c9400b7d902d6e61", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..ad9ccbb --- /dev/null +++ b/flake.nix @@ -0,0 +1,108 @@ +{ + description = "brewlog"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + rust-overlay.url = "github:oxalica/rust-overlay"; + }; + + outputs = + { + self, + nixpkgs, + rust-overlay, + }: + let + supportedSystems = [ + "x86_64-linux" + "aarch64-linux" + ]; + forAllSystems = nixpkgs.lib.genAttrs supportedSystems; + + pkgsForSystem = + system: + (import nixpkgs { + inherit system; + overlays = [ (import rust-overlay) ]; + }); + in + { + packages = forAllSystems ( + system: + let + inherit (pkgsForSystem system) + lib + rustPlatform + pkg-config + openssl + ; + + cargoToml = lib.trivial.importTOML ./Cargo.toml; + version = cargoToml.package.version; + in + rec { + default = brewlog; + + brewlog = rustPlatform.buildRustPackage { + pname = "brewlog"; + version = version; + src = lib.cleanSource ./.; + cargoLock.lockFile = ./Cargo.lock; + + nativeBuildInputs = [ pkg-config ]; + + buildInputs = [ openssl ]; + + meta = { + description = "Log your favourite roasters, roasts, brews and cafes!"; + homepage = "https://github.com/jnsgruk/brewlog"; + license = lib.licenses.asl20; + mainProgram = "brewlog"; + platforms = lib.platforms.unix; + maintainers = with lib.maintainers; [ jnsgruk ]; + }; + }; + } + ); + + devShells = forAllSystems ( + system: + let + pkgs = pkgsForSystem system; + rust = pkgs.rust-bin.stable.latest.default.override { + extensions = [ + "rust-src" + "clippy" + "rust-analyzer" + "rustfmt" + ]; + }; + in + { + default = pkgs.mkShell { + name = "brewlog"; + + NIX_CONFIG = "experimental-features = nix-command flakes"; + RUST_SRC_PATH = "${rust}/lib/rustlib/src/rust/library"; + LD_LIBRARY_PATH = with pkgs; lib.makeLibraryPath [ openssl ]; + + inputsFrom = [ self.packages.${system}.brewlog ]; + buildInputs = + with pkgs; + [ + cargo-watch + clang + lld + nil + nixfmt-rfc-style + sqlx-cli + sqlite + ] + ++ [ + rust + ]; + }; + } + ); + }; +} diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql new file mode 100644 index 0000000..b5c0175 --- /dev/null +++ b/migrations/0001_init.sql @@ -0,0 +1,93 @@ +-- migrate:up +PRAGMA foreign_keys = ON; + +CREATE TABLE roasters ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + country TEXT NOT NULL, + city TEXT, + homepage TEXT, + notes TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE TABLE roasts ( + id TEXT PRIMARY KEY, + roaster_id TEXT NOT NULL REFERENCES roasters(id) ON DELETE CASCADE, + name TEXT NOT NULL, + origin TEXT, + region TEXT, + producer TEXT, + process TEXT, + tasting_notes TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE TABLE cafes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + latitude REAL, + longitude REAL, + website TEXT, + address TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE TABLE gear ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + gear_type TEXT NOT NULL CHECK (gear_type IN ('grinder','brewer','kettle','filter_paper')), + manufacturer TEXT, + model TEXT, + notes TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE TABLE brews ( + id TEXT PRIMARY KEY, + roast_id TEXT NOT NULL REFERENCES roasts(id) ON DELETE RESTRICT, + method TEXT NOT NULL, + dose_grams REAL, + water_grams REAL, + brew_temperature_c REAL, + grind_setting TEXT, + brewed_at TEXT NOT NULL, + notes TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE TABLE brew_gear ( + brew_id TEXT NOT NULL REFERENCES brews(id) ON DELETE CASCADE, + gear_id TEXT NOT NULL REFERENCES gear(id) ON DELETE RESTRICT, + PRIMARY KEY (brew_id, gear_id) +); + +CREATE TABLE cups ( + id TEXT PRIMARY KEY, + cafe_id TEXT NOT NULL REFERENCES cafes(id) ON DELETE RESTRICT, + roast_id TEXT REFERENCES roasts(id) ON DELETE SET NULL, + price_cents INTEGER, + consumed_at TEXT NOT NULL, + notes TEXT, + rating INTEGER CHECK (rating BETWEEN 0 AND 255), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX idx_roasts_roaster_id ON roasts(roaster_id); +CREATE INDEX idx_brews_roast_id ON brews(roast_id); +CREATE INDEX idx_brew_gear_gear_id ON brew_gear(gear_id); +CREATE INDEX idx_cups_cafe_id ON cups(cafe_id); +CREATE INDEX idx_cups_roast_id ON cups(roast_id); + +CREATE TABLE timeline_events ( + id TEXT PRIMARY KEY, + entity_type TEXT NOT NULL CHECK (entity_type IN ('roaster', 'roast')), + entity_id TEXT NOT NULL, + occurred_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + title TEXT NOT NULL, + details_json TEXT, + tasting_notes_json TEXT +); + +CREATE INDEX idx_timeline_events_entity ON timeline_events(entity_type, entity_id); +CREATE INDEX idx_timeline_events_occurred_at ON timeline_events(occurred_at); diff --git a/scripts/bootstrap-db.sh b/scripts/bootstrap-db.sh new file mode 100755 index 0000000..0174f2e --- /dev/null +++ b/scripts/bootstrap-db.sh @@ -0,0 +1,407 @@ +#!/usr/bin/env bash + +cargo build + +# Tim Wendelboe (Norway) +./target/debug/brewlog add-roaster \ + --name "Tim Wendelboe" \ + --country "Norway" \ + --city "Oslo" \ + --homepage "https://timwendelboe.no" \ + --notes "World-renowned Nordic micro-roastery dedicated to clarity and sustainability." + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Tim Wendelboe") | .id')" \ + --name "Ben Saïd Natural" \ + --origin "Ethiopia" \ + --region "Sidamo" \ + --producer "Ben Saïd" \ + --process "Natural" \ + --tasting-notes "Bergamot, Apricot, Floral" + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Tim Wendelboe") | .id')" \ + --name "Finca Tamana Washed" \ + --origin "Colombia" \ + --region "El Pital, Huila" \ + --producer "Elias Roa" \ + --process "Washed" \ + --tasting-notes "Red Apple, Vanilla, Caramel" + + +# Coffee Collective (Denmark) +./target/debug/brewlog add-roaster \ + --name "Coffee Collective" \ + --country "Denmark" \ + --city "Copenhagen" \ + --homepage "https://coffeecollective.dk" \ + --notes "Pioneers of transparency and sustainability; multi-time Nordic roaster award winners." + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Coffee Collective") | .id')" \ + --name "Daterra Sweet Collection" \ + --origin "Brazil" \ + --region "Cerrado" \ + --producer "Daterra" \ + --process "Pulped Natural" \ + --tasting-notes "Hazelnut, Milk Chocolate, Yellow Fruit" + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Coffee Collective") | .id')" \ + --name "Kieni" \ + --origin "Kenya" \ + --region "Nyeri" \ + --producer "Kieni Factory" \ + --process "Washed" \ + --tasting-notes "Currant, Black Tea, Grape" + + +# Drop Coffee (Sweden) +./target/debug/brewlog add-roaster \ + --name "Drop Coffee" \ + --country "Sweden" \ + --city "Stockholm" \ + --homepage "https://dropcoffee.com" \ + --notes "Award-winning Swedish roastery prized for its elegance and clean Scandinavian style." + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Drop Coffee") | .id')" \ + --name "La Linda" \ + --origin "Bolivia" \ + --region "Caranavi" \ + --producer "Pedro Rodriguez" \ + --process "Washed" \ + --tasting-notes "Red Apple, Caramel, Floral" + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Drop Coffee") | .id')" \ + --name "El Sunzita" \ + --origin "El Salvador" \ + --region "Ahuachapan" \ + --producer "Jorge Raul Rivera" \ + --process "Natural" \ + --tasting-notes "Strawberry, Mango, Dark Chocolate" + + +# La Cabra (Denmark) +./target/debug/brewlog add-roaster \ + --name "La Cabra" \ + --country "Denmark" \ + --city "Aarhus" \ + --homepage "https://www.lacabra.dk" \ + --notes "Scandinavian minimalist roastery known for clarity and innovative sourcing." + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="La Cabra") | .id')" \ + --name "Halo Beriti" \ + --origin "Ethiopia" \ + --region "Yirgacheffe" \ + --producer "Halo Beriti Cooperative" \ + --process "Washed" \ + --tasting-notes "Jasmine, Lemon, Stone Fruit" + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="La Cabra") | .id')" \ + --name "Cerro Azul" \ + --origin "Colombia" \ + --region "Valle del Cauca" \ + --producer "Granja La Esperanza" \ + --process "Washed" \ + --tasting-notes "Blueberry, Plum, Grapefruit" + + +# April Coffee (Denmark) +./target/debug/brewlog add-roaster \ + --name "April Coffee" \ + --country "Denmark" \ + --city "Copenhagen" \ + --homepage "https://aprilcoffeeroasters.com" \ + --notes "Modern approach to Nordic coffee, emphasizing transparency and traceability." + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="April Coffee") | .id')" \ + --name "El Salvador Pacamara" \ + --origin "El Salvador" \ + --region "Santa Ana" \ + --producer "Ernesto Menendez" \ + --process "Honey" \ + --tasting-notes "Grapefruit, Sugar Cane, Plum" + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="April Coffee") | .id')" \ + --name "Guji Highland" \ + --origin "Ethiopia" \ + --region "Guji" \ + --producer "Andualem Abebe" \ + --process "Natural" \ + --tasting-notes "Peach, Strawberry, Cream" + + +# Assembly Coffee (UK) +./target/debug/brewlog add-roaster \ + --name "Assembly Coffee" \ + --country "UK" \ + --city "London" \ + --homepage "https://assemblycoffee.co.uk" \ + --notes "Based in Brixton, Assembly focuses on collaborative sourcing and education." + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Assembly Coffee") | .id')" \ + --name "Kochere" \ + --origin "Ethiopia" \ + --region "Yirgacheffe" \ + --producer "Kochere Region Growers" \ + --process "Washed" \ + --tasting-notes "Peach, Lemon, Jasmine" + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Assembly Coffee") | .id')" \ + --name "La Laja" \ + --origin "Mexico" \ + --region "Veracruz" \ + --producer "La Laja Estate" \ + --process "Natural" \ + --tasting-notes "Cherry, Milk Chocolate, Praline" + + +# Square Mile (UK) +./target/debug/brewlog add-roaster \ + --name "Square Mile Coffee" \ + --country "UK" \ + --city "London" \ + --homepage "https://squaremilecoffee.com" \ + --notes "One of London's pioneers; delivers balanced and clear, fruit-forward coffees." + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Square Mile Coffee") | .id')" \ + --name "Red Brick Espresso" \ + --origin "Blend" \ + --region "Multiple Origins" \ + --producer "Various" \ + --process "Washed, Natural" \ + --tasting-notes "Berry, Chocolate, Citrus" + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Square Mile Coffee") | .id')" \ + --name "Kamwangi" \ + --origin "Kenya" \ + --region "Kirinyaga" \ + --producer "Kamwangi Factory" \ + --process "Washed" \ + --tasting-notes "Blackcurrant, Rhubarb, Blood Orange" + + +# Dak Coffee Roasters (Netherlands) +./target/debug/brewlog add-roaster \ + --name "Dak Coffee Roasters" \ + --country "Netherlands" \ + --city "Amsterdam" \ + --homepage "https://www.dakcoffeeroasters.com" \ + --notes "Highly experimental Dutch roastery; celebrates vibrant acidity and alternative processing." + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Dak Coffee Roasters") | .id')" \ + --name "El Paraiso 92 Anaerobic" \ + --origin "Colombia" \ + --region "Cauca" \ + --producer "Diego Bermudez" \ + --process "Thermal Shock Anaerobic" \ + --tasting-notes "Passionfruit, Raspberry, Yogurt" + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Dak Coffee Roasters") | .id')" \ + --name "Oreti SL28" \ + --origin "Kenya" \ + --region "Kirinyaga" \ + --producer "Oreti Estate" \ + --process "Washed" \ + --tasting-notes "Grapefruit, Blackcurrant, Plum" + + +# Bonanza Coffee (Germany) +./target/debug/brewlog add-roaster \ + --name "Bonanza Coffee" \ + --country "Germany" \ + --city "Berlin" \ + --homepage "https://www.bonanzacoffee.de" \ + --notes "Pioneering Berlin roastery focused on brightness, balance, and freshness." + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Bonanza Coffee") | .id')" \ + --name "Gatomboya" \ + --origin "Kenya" \ + --region "Nyeri" \ + --producer "Gatomboya Cooperative" \ + --process "Washed" \ + --tasting-notes "Blackcurrant, Lime, Tomato" + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Bonanza Coffee") | .id')" \ + --name "Los Pirineos" \ + --origin "El Salvador" \ + --region "Usulután" \ + --producer "Gilberto Baraona" \ + --process "Honey" \ + --tasting-notes "Maple, Fudge, Green Apple" + + +# Friedhats (Netherlands) +./target/debug/brewlog add-roaster \ + --name "Friedhats" \ + --country "Netherlands" \ + --city "Amsterdam" \ + --homepage "https://friedhats.com" \ + --notes "Quirky branding meets serious, awarded, fruit-forward coffees from Amsterdam." + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Friedhats") | .id')" \ + --name "Sidamo Guji" \ + --origin "Ethiopia" \ + --region "Guji" \ + --producer "Smallholders" \ + --process "Natural" \ + --tasting-notes "Peach, Raspberry, Rosehip" + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Friedhats") | .id')" \ + --name "La Esmeralda Geisha" \ + --origin "Panama" \ + --region "Boquete" \ + --producer "Hacienda La Esmeralda" \ + --process "Washed" \ + --tasting-notes "Jasmine, Bergamot, Papaya" + + +# Origin Coffee (UK) +./target/debug/brewlog add-roaster \ + --name "Origin Coffee" \ + --country "UK" \ + --city "Porthleven" \ + --homepage "https://origincoffee.co.uk" \ + --notes "Specialty roaster with close partnerships at origin; leading UK scene with cutting-edge lots." + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Origin Coffee") | .id')" \ + --name "San Fermin" \ + --origin "Colombia" \ + --region "Tolima" \ + --producer "San Fermin Smallholders" \ + --process "Washed" \ + --tasting-notes "Red Grape, Caramel, Blood Orange" + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Origin Coffee") | .id')" \ + --name "Aricha" \ + --origin "Ethiopia" \ + --region "Yirgacheffe" \ + --producer "Aricha Washing Station" \ + --process "Washed" \ + --tasting-notes "Honey, Peach, Black Tea" + + +# Dark Arts Coffee (UK) +./target/debug/brewlog add-roaster \ + --name "Dark Arts Coffee" \ + --country "UK" \ + --city "London" \ + --homepage "https://www.darkartscoffee.co.uk" \ + --notes "Playful, disruptive roaster with a cult following and flavor-forward offerings." + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Dark Arts Coffee") | .id')" \ + --name "Death to Decaf" \ + --origin "Brazil" \ + --region "Minas Gerais" \ + --producer "Carmo de Minas" \ + --process "Swiss Water Decaf" \ + --tasting-notes "Cocoa, Cherry, Almond" + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Dark Arts Coffee") | .id')" \ + --name "Snoop" \ + --origin "Guatemala" \ + --region "Huehuetenango" \ + --producer "Various Smallholders" \ + --process "Washed" \ + --tasting-notes "Toffee, Green Apple, Plum" + + +# KAWA Coffee (France) +./target/debug/brewlog add-roaster \ + --name "KAWA Coffee" \ + --country "France" \ + --city "Paris" \ + --homepage "https://www.kawa.coffee" \ + --notes "One of Paris’ most exciting specialty roasteries, known for unusual and competition-level lots." + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="KAWA Coffee") | .id')" \ + --name "Sudan Rume" \ + --origin "Colombia" \ + --region "Cauca" \ + --producer "Granja La Esperanza" \ + --process "Natural" \ + --tasting-notes "Strawberry, Cinnamon, Grape" + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="KAWA Coffee") | .id')" \ + --name "Arbegona" \ + --origin "Ethiopia" \ + --region "Sidama" \ + --producer "Arbegona Washing Station" \ + --process "Washed" \ + --tasting-notes "Violet, Apricot, Lemon" + + +# Stow Coffee (Slovenia) +./target/debug/brewlog add-roaster \ + --name "Stow Coffee" \ + --country "Slovenia" \ + --city "Ljubljana" \ + --homepage "https://www.stowcoffee.com" \ + --notes "Slovenia’s specialty leader, awarded for pure, brightly acidic profiles and innovation." + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Stow Coffee") | .id')" \ + --name "Santa Barbara" \ + --origin "Honduras" \ + --region "Santa Barbara" \ + --producer "Benjamin Paz" \ + --process "Honey" \ + --tasting-notes "Red Currant, Honeydew, Cocoa" + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Stow Coffee") | .id')" \ + --name "Suke Quto" \ + --origin "Ethiopia" \ + --region "Guji" \ + --producer "Tesfaye Bekele" \ + --process "Natural" \ + --tasting-notes "Blackberry, Vanilla, Jasmine" + + +# Bows Coffee (Canada) +./target/debug/brewlog add-roaster \ + --name "Bows Coffee" \ + --country "Canada" \ + --city "Victoria" \ + --homepage "https://bowscoffee.com" \ + --notes "Canadian micro-roaster with focus on clarity, complexity, and ethical sourcing." + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Bows Coffee") | .id')" \ + --name "La Chumeca" \ + --origin "Costa Rica" \ + --region "Tarrazú" \ + --producer "Doña Olga Jiménez" \ + --process "White Honey" \ + --tasting-notes "Mandarin, Honeycomb, Almond" + +./target/debug/brewlog add-roast \ + --roaster-id "$(./target/debug/brewlog list-roasters | jq -r '.[] | select(.name=="Bows Coffee") | .id')" \ + --name "Simbi" \ + --origin "Rwanda" \ + --region "Huye" \ + --producer "Simbi Co-op" \ + --process "Washed" \ + --tasting-notes "Black Tea, Orange, Cane Sugar" diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..c159727 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,73 @@ +pub mod roasters; +pub mod roasts; + +use std::net::SocketAddr; + +use clap::{Args, Parser, Subcommand}; +use roasters::{AddRoasterCommand, DeleteRoasterCommand, GetRoasterCommand, UpdateRoasterCommand}; +use roasts::{AddRoastCommand, DeleteRoastCommand, GetRoastCommand, ListRoastsCommand}; + +#[derive(Debug, Parser)] +#[command(author, version, about = "Track coffee roasts, brews, and cups", long_about = None)] +pub struct Cli { + #[arg( + long, + global = true, + env = "BREWLOG_URL", + default_value = "http://127.0.0.1:3000" + )] + pub api_url: String, + + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Debug, Subcommand)] +pub enum Commands { + #[command(name = "serve")] + Serve(ServeCommand), + + // Roasters + #[command(name = "add-roaster")] + AddRoaster(AddRoasterCommand), + #[command(name = "list-roasters")] + ListRoasters, + #[command(name = "get-roaster")] + GetRoaster(GetRoasterCommand), + #[command(name = "update-roaster")] + UpdateRoaster(UpdateRoasterCommand), + #[command(name = "delete-roaster")] + DeleteRoaster(DeleteRoasterCommand), + + // Roasts + #[command(name = "add-roast")] + AddRoast(AddRoastCommand), + #[command(name = "list-roasts")] + ListRoasts(ListRoastsCommand), + #[command(name = "get-roast")] + GetRoast(GetRoastCommand), + #[command(name = "delete-roast")] + DeleteRoast(DeleteRoastCommand), +} + +#[derive(Debug, Args)] +pub struct ServeCommand { + #[arg( + long, + env = "BREWLOG_DATABASE_URL", + default_value = "sqlite://brewlog.db" + )] + pub database_url: String, + + #[arg(long, env = "BREWLOG_BIND_ADDRESS", default_value = "127.0.0.1:3000")] + pub bind_address: SocketAddr, +} + +pub(crate) fn print_json(value: &T) -> anyhow::Result<()> +where + T: serde::Serialize, +{ + let rendered = serde_json::to_string_pretty(value)?; + println!("{rendered}"); + Ok(()) +} diff --git a/src/cli/roasters.rs b/src/cli/roasters.rs new file mode 100644 index 0000000..63397b5 --- /dev/null +++ b/src/cli/roasters.rs @@ -0,0 +1,96 @@ +use anyhow::Result; +use clap::Args; +use serde_json::json; + +use super::print_json; +use crate::client::BrewlogClient; +use crate::domain::roasters::{NewRoaster, UpdateRoaster}; + +#[derive(Debug, Args)] +pub struct AddRoasterCommand { + #[arg(long)] + pub name: String, + #[arg(long)] + pub country: String, + #[arg(long)] + pub city: Option, + #[arg(long)] + pub homepage: Option, + #[arg(long)] + pub notes: Option, +} + +pub async fn add_roaster(client: &BrewlogClient, command: AddRoasterCommand) -> Result<()> { + let payload = NewRoaster { + name: command.name, + country: command.country, + city: command.city, + homepage: command.homepage, + notes: command.notes, + }; + + let roaster = client.roasters().create(&payload).await?; + print_json(&roaster) +} + +pub async fn list_roasters(client: &BrewlogClient) -> Result<()> { + let roasters = client.roasters().list().await?; + print_json(&roasters) +} + +#[derive(Debug, Args)] +pub struct GetRoasterCommand { + #[arg(long)] + pub id: String, +} + +pub async fn get_roaster(client: &BrewlogClient, command: GetRoasterCommand) -> Result<()> { + let roaster = client.roasters().get(&command.id).await?; + print_json(&roaster) +} + +#[derive(Debug, Args)] +pub struct UpdateRoasterCommand { + #[arg(long)] + pub id: String, + #[arg(long)] + pub name: Option, + #[arg(long)] + pub country: Option, + #[arg(long)] + pub city: Option, + #[arg(long)] + pub homepage: Option, + #[arg(long)] + pub notes: Option, +} + +pub async fn update_roaster(client: &BrewlogClient, command: UpdateRoasterCommand) -> Result<()> { + let payload = UpdateRoaster { + name: command.name, + country: command.country, + city: command.city, + homepage: command.homepage, + notes: command.notes, + }; + + let roaster = client.roasters().update(&command.id, &payload).await?; + print_json(&roaster) +} + +#[derive(Debug, Args)] +pub struct DeleteRoasterCommand { + #[arg(long)] + pub id: String, +} + +pub async fn delete_roaster(client: &BrewlogClient, command: DeleteRoasterCommand) -> Result<()> { + let id = command.id; + client.roasters().delete(&id).await?; + let response = json!({ + "status": "deleted", + "resource": "roaster", + "id": id, + }); + print_json(&response) +} diff --git a/src/cli/roasts.rs b/src/cli/roasts.rs new file mode 100644 index 0000000..18ee421 --- /dev/null +++ b/src/cli/roasts.rs @@ -0,0 +1,79 @@ +use anyhow::Result; +use clap::Args; +use serde_json::json; + +use super::print_json; +use crate::client::BrewlogClient; +use crate::domain::roasts::NewRoast; + +#[derive(Debug, Args)] +pub struct AddRoastCommand { + #[arg(long)] + pub roaster_id: String, + #[arg(long)] + pub name: String, + #[arg(long)] + pub origin: Option, + #[arg(long)] + pub region: Option, + #[arg(long)] + pub producer: Option, + #[arg(long)] + pub process: Option, + #[arg(long = "tasting-notes")] + pub tasting_notes: Vec, +} + +pub async fn add_roast(client: &BrewlogClient, command: AddRoastCommand) -> Result<()> { + let payload = NewRoast { + roaster_id: command.roaster_id, + name: command.name, + origin: command.origin, + region: command.region, + producer: command.producer, + tasting_notes: command.tasting_notes, + process: command.process, + }; + + let roast = client.roasts().create(&payload).await?; + print_json(&roast) +} + +#[derive(Debug, Args)] +pub struct ListRoastsCommand { + #[arg(long)] + pub roaster_id: Option, +} + +pub async fn list_roasts(client: &BrewlogClient, command: ListRoastsCommand) -> Result<()> { + let roasts = client.roasts().list(command.roaster_id.as_deref()).await?; + print_json(&roasts) +} + +#[derive(Debug, Args)] +pub struct GetRoastCommand { + #[arg(long)] + pub id: String, +} + +pub async fn get_roast(client: &BrewlogClient, command: GetRoastCommand) -> Result<()> { + let roast = client.roasts().get(&command.id).await?; + print_json(&roast) +} + +#[derive(Debug, Args)] +pub struct DeleteRoastCommand { + #[arg(long)] + pub id: String, +} + +pub async fn delete_roast(client: &BrewlogClient, command: DeleteRoastCommand) -> Result<()> { + let id = command.id; + client.roasts().delete(&id).await?; + let response = json!({ + "status": "deleted", + "resource": "roast", + "id": id, + }); + print_json(&response) +} diff --git a/src/client/mod.rs b/src/client/mod.rs new file mode 100644 index 0000000..7e13fca --- /dev/null +++ b/src/client/mod.rs @@ -0,0 +1,80 @@ +pub mod roasters; +pub mod roasts; + +use anyhow::{Context, Result, anyhow}; +use reqwest::{Client, Url}; + +use crate::server::errors::ErrorResponse; + +pub struct BrewlogClient { + base_url: Url, + http: Client, +} + +impl BrewlogClient { + pub fn new(base_url: Url) -> Result { + let mut normalized = base_url; + if !normalized.path().ends_with('/') { + normalized.set_path(&format!("{}/", normalized.path().trim_end_matches('/'))); + } + + let http = Client::builder() + .user_agent("brewlog-cli/0.1") + .build() + .context("failed to configure HTTP client")?; + + Ok(Self { + base_url: normalized, + http, + }) + } + + pub fn from_base_url(base_url: &str) -> Result { + let url = Url::parse(base_url).with_context(|| format!("invalid API url: {base_url}"))?; + Self::new(url) + } + + pub fn roasters(&self) -> roasters::RoastersClient<'_> { + roasters::RoastersClient::new(self) + } + + pub fn roasts(&self) -> roasts::RoastsClient<'_> { + roasts::RoastsClient::new(self) + } + + pub(crate) fn endpoint(&self, path: &str) -> Result { + self.base_url + .join(path) + .with_context(|| format!("invalid API path: {path}")) + } + + pub(crate) fn http_client(&self) -> &Client { + &self.http + } + + pub(crate) async fn handle_response(&self, response: reqwest::Response) -> Result + where + T: serde::de::DeserializeOwned, + { + if response.status().is_success() { + response + .json::() + .await + .context("failed to deserialize response body") + } else { + Err(self.response_error(response).await) + } + } + + pub(crate) async fn response_error(&self, response: reqwest::Response) -> anyhow::Error { + let status = response.status(); + let bytes = response.bytes().await.unwrap_or_default(); + + if let Ok(err) = serde_json::from_slice::(&bytes) { + return anyhow!("request failed ({status}): {}", err.message); + } + + let message = String::from_utf8_lossy(&bytes); + anyhow!("request failed ({status}): {message}") + } +} diff --git a/src/client/roasters.rs b/src/client/roasters.rs new file mode 100644 index 0000000..b887d65 --- /dev/null +++ b/src/client/roasters.rs @@ -0,0 +1,86 @@ +use anyhow::{Context, Result}; +use reqwest::StatusCode; + +use crate::domain::roasters::{NewRoaster, Roaster, UpdateRoaster}; + +use super::BrewlogClient; + +pub struct RoastersClient<'a> { + inner: &'a BrewlogClient, +} + +impl<'a> RoastersClient<'a> { + pub(crate) fn new(inner: &'a BrewlogClient) -> Self { + Self { inner } + } + + pub async fn create(&self, payload: &NewRoaster) -> Result { + let url = self.inner.endpoint("api/v1/roasters")?; + let response = self + .inner + .http_client() + .post(url) + .json(payload) + .send() + .await + .context("failed to issue create roaster request")?; + + self.inner.handle_response(response).await + } + + pub async fn list(&self) -> Result> { + let url = self.inner.endpoint("api/v1/roasters")?; + let response = self + .inner + .http_client() + .get(url) + .send() + .await + .context("failed to issue list roasters request")?; + + self.inner.handle_response(response).await + } + + pub async fn get(&self, id: &str) -> Result { + let url = self.inner.endpoint(&format!("api/v1/roasters/{id}"))?; + let response = self + .inner + .http_client() + .get(url) + .send() + .await + .context("failed to issue get roaster request")?; + + self.inner.handle_response(response).await + } + + pub async fn update(&self, id: &str, payload: &UpdateRoaster) -> Result { + let url = self.inner.endpoint(&format!("api/v1/roasters/{id}"))?; + let response = self + .inner + .http_client() + .put(url) + .json(payload) + .send() + .await + .context("failed to issue update roaster request")?; + + self.inner.handle_response(response).await + } + + pub async fn delete(&self, id: &str) -> Result<()> { + let url = self.inner.endpoint(&format!("api/v1/roasters/{id}"))?; + let response = self + .inner + .http_client() + .delete(url) + .send() + .await + .context("failed to issue delete roaster request")?; + + match response.status() { + StatusCode::NO_CONTENT => Ok(()), + _ => Err(self.inner.response_error(response).await), + } + } +} diff --git a/src/client/roasts.rs b/src/client/roasts.rs new file mode 100644 index 0000000..ec5332e --- /dev/null +++ b/src/client/roasts.rs @@ -0,0 +1,76 @@ +use anyhow::{Context, Result}; +use reqwest::StatusCode; + +use crate::domain::roasts::{NewRoast, Roast, RoastWithRoaster}; + +use super::BrewlogClient; + +pub struct RoastsClient<'a> { + inner: &'a BrewlogClient, +} + +impl<'a> RoastsClient<'a> { + pub(crate) fn new(inner: &'a BrewlogClient) -> Self { + Self { inner } + } + + pub async fn create(&self, payload: &NewRoast) -> Result { + let url = self.inner.endpoint("api/v1/roasts")?; + let response = self + .inner + .http_client() + .post(url) + .json(payload) + .send() + .await + .context("failed to issue create roast request")?; + + self.inner.handle_response(response).await + } + + pub async fn list(&self, roaster_id: Option<&str>) -> Result> { + let mut url = self.inner.endpoint("api/v1/roasts")?; + if let Some(roaster_id) = roaster_id { + url.query_pairs_mut().append_pair("roaster_id", roaster_id); + } + + let response = self + .inner + .http_client() + .get(url) + .send() + .await + .context("failed to issue list roasts request")?; + + self.inner.handle_response(response).await + } + + pub async fn get(&self, id: &str) -> Result { + let url = self.inner.endpoint(&format!("api/v1/roasts/{id}"))?; + let response = self + .inner + .http_client() + .get(url) + .send() + .await + .context("failed to issue get roast request")?; + + self.inner.handle_response(response).await + } + + pub async fn delete(&self, id: &str) -> Result<()> { + let url = self.inner.endpoint(&format!("api/v1/roasts/{id}"))?; + let response = self + .inner + .http_client() + .delete(url) + .send() + .await + .context("failed to issue delete roast request")?; + + match response.status() { + StatusCode::NO_CONTENT => Ok(()), + _ => Err(self.inner.response_error(response).await), + } + } +} diff --git a/src/domain/ids.rs b/src/domain/ids.rs new file mode 100644 index 0000000..d823216 --- /dev/null +++ b/src/domain/ids.rs @@ -0,0 +1,14 @@ +use block_id::{Alphabet, BlockId as BlockIdGenerator}; +use once_cell::sync::Lazy; +use rand::RngCore; + +static ID_GENERATOR: Lazy> = + Lazy::new(|| BlockIdGenerator::new(Alphabet::alphanumeric(), 0xB10C_1D_u128, 4)); + +pub fn generate_id() -> String { + let mut rng = rand::thread_rng(); + let value = rng.next_u64(); + ID_GENERATOR + .encode_string(value) + .expect("block-id encoding should succeed") +} diff --git a/src/domain/listing.rs b/src/domain/listing.rs new file mode 100644 index 0000000..7e9c1ac --- /dev/null +++ b/src/domain/listing.rs @@ -0,0 +1,230 @@ +use std::cmp; + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum SortDirection { + Asc, + Desc, +} + +impl SortDirection { + pub const fn as_str(self) -> &'static str { + match self { + SortDirection::Asc => "asc", + SortDirection::Desc => "desc", + } + } + + pub const fn opposite(self) -> Self { + match self { + SortDirection::Asc => SortDirection::Desc, + SortDirection::Desc => SortDirection::Asc, + } + } +} + +pub trait SortKey: Copy + Eq { + fn default() -> Self; + fn from_query(value: &str) -> Option; + fn query_value(self) -> &'static str; + fn default_direction(self) -> SortDirection; +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum PageSize { + Limited(u32), + All, +} + +impl PageSize { + pub fn limited(size: u32) -> Self { + if size == 0 { + PageSize::All + } else { + PageSize::Limited(size) + } + } + + pub const fn is_all(self) -> bool { + matches!(self, PageSize::All) + } + + pub const fn as_option(self) -> Option { + match self { + PageSize::Limited(value) => Some(value), + PageSize::All => None, + } + } + + pub fn to_query_value(self) -> String { + match self { + PageSize::All => "all".to_string(), + PageSize::Limited(value) => value.to_string(), + } + } +} + +pub const DEFAULT_PAGE_SIZE: u32 = 10; +pub const MAX_PAGE_SIZE: u32 = 50; + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct ListRequest { + page: u32, + page_size: PageSize, + sort_key: K, + sort_direction: SortDirection, +} + +impl ListRequest { + pub fn new(page: u32, page_size: PageSize, sort_key: K, sort_direction: SortDirection) -> Self { + let page = page.max(1); + let page_size = match page_size { + PageSize::Limited(size) => { + if size == 0 { + PageSize::All + } else { + let clamped = cmp::min(size, MAX_PAGE_SIZE).max(1); + PageSize::Limited(clamped) + } + } + PageSize::All => PageSize::All, + }; + + Self { + page, + page_size, + sort_key, + sort_direction, + } + } + + pub fn default() -> Self { + let key = K::default(); + Self::new( + 1, + PageSize::Limited(DEFAULT_PAGE_SIZE), + key, + key.default_direction(), + ) + } + + pub fn show_all(sort_key: K, sort_direction: SortDirection) -> Self { + Self::new(1, PageSize::All, sort_key, sort_direction) + } + + pub const fn page(&self) -> u32 { + self.page + } + + pub const fn page_size(&self) -> PageSize { + self.page_size + } + + pub const fn sort_key(&self) -> K { + self.sort_key + } + + pub const fn sort_direction(&self) -> SortDirection { + self.sort_direction + } + + pub fn with_page(self, page: u32) -> Self { + Self { + page: page.max(1), + ..self + } + } + + pub fn with_page_size(self, page_size: PageSize) -> Self { + Self::new(self.page, page_size, self.sort_key, self.sort_direction) + } + + pub fn with_sort(self, key: K) -> Self { + let direction = if key == self.sort_key { + self.sort_direction.opposite() + } else { + key.default_direction() + }; + Self::new(self.page, self.page_size, key, direction) + } + + pub fn with_sort_and_direction(self, key: K, direction: SortDirection) -> Self { + Self::new(self.page, self.page_size, key, direction) + } + + pub fn ensure_page_within(self, total: u64) -> Self { + if matches!(self.page_size, PageSize::All) { + return Self::new(1, PageSize::All, self.sort_key, self.sort_direction); + } + + let Some(limit) = self.page_size.as_option() else { + return self; + }; + + if total == 0 { + return Self::new(1, self.page_size, self.sort_key, self.sort_direction); + } + + let last_page = ((total + limit as u64 - 1) / limit as u64) as u32; + let adjusted_page = self.page.min(last_page.max(1)); + Self::new( + adjusted_page, + self.page_size, + self.sort_key, + self.sort_direction, + ) + } +} + +#[derive(Debug, Clone)] +pub struct Page { + pub items: Vec, + pub page: u32, + pub page_size: u32, + pub total: u64, + pub showing_all: bool, +} + +impl Page { + pub fn new(items: Vec, page: u32, page_size: u32, total: u64, showing_all: bool) -> Self { + Self { + items, + page: page.max(1), + page_size: page_size.max(1), + total, + showing_all, + } + } + + pub fn total_pages(&self) -> u32 { + if self.total == 0 || self.showing_all { + 1 + } else { + let size = self.page_size as u64; + ((self.total + size - 1) / size) as u32 + } + } + + pub fn has_previous(&self) -> bool { + !self.showing_all && self.page > 1 + } + + pub fn has_next(&self) -> bool { + !self.showing_all && self.page < self.total_pages() + } + + pub fn start_index(&self) -> u64 { + if self.total == 0 { + 0 + } else { + ((self.page - 1) as u64) * self.page_size as u64 + 1 + } + } + + pub fn end_index(&self) -> u64 { + if self.total == 0 { + 0 + } else { + self.start_index() + self.items.len() as u64 - 1 + } + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs new file mode 100644 index 0000000..46b73e4 --- /dev/null +++ b/src/domain/mod.rs @@ -0,0 +1,32 @@ +pub mod ids; +pub mod listing; +pub mod origins; +pub mod repositories; +pub mod roasters; +pub mod roasts; +pub mod timeline; +use std::fmt::Display; + +use thiserror::Error; + +// TODO: This should probably be in a file named errors.rs +// so that it's domain::errors::RepositoryError. +#[derive(Debug, Error)] +pub enum RepositoryError { + #[error("entity not found")] + NotFound, + #[error("conflict: {0}")] + Conflict(String), + #[error("unexpected data store error: {0}")] + Unexpected(String), +} + +impl RepositoryError { + pub fn conflict(message: T) -> Self { + Self::Conflict(message.to_string()) + } + + pub fn unexpected(message: T) -> Self { + Self::Unexpected(message.to_string()) + } +} diff --git a/src/domain/origins.rs b/src/domain/origins.rs new file mode 100644 index 0000000..e1d4c93 --- /dev/null +++ b/src/domain/origins.rs @@ -0,0 +1,13 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Origin { + pub id: String, + pub name: String, + pub country: String, + pub region: Option, + pub elevation_masl: Option, + pub notes: Option, + pub created_at: DateTime, +} diff --git a/src/domain/repositories.rs b/src/domain/repositories.rs new file mode 100644 index 0000000..08ee996 --- /dev/null +++ b/src/domain/repositories.rs @@ -0,0 +1,67 @@ +use super::RepositoryError; +use crate::domain::listing::{ListRequest, Page, SortDirection, SortKey}; + +use crate::domain::roasters::RoasterSortKey; +use crate::domain::roasters::{Roaster, UpdateRoaster}; +use crate::domain::roasts::RoastSortKey; +use crate::domain::roasts::{Roast, RoastWithRoaster, UpdateRoast}; +use crate::domain::timeline::TimelineEvent; +use async_trait::async_trait; + +#[async_trait] +pub trait RoasterRepository: Send + Sync { + async fn insert(&self, roaster: Roaster) -> Result; + async fn get(&self, id: String) -> Result; + async fn list( + &self, + request: &ListRequest, + ) -> Result, RepositoryError>; + async fn update(&self, id: String, changes: UpdateRoaster) -> Result; + async fn delete(&self, id: String) -> Result<(), RepositoryError>; + + async fn list_all(&self) -> Result, RepositoryError> { + let sort_key = ::default(); + let request = + ListRequest::::show_all(sort_key, sort_key.default_direction()); + let page = self.list(&request).await?; + Ok(page.items) + } + + async fn list_all_sorted( + &self, + sort_key: RoasterSortKey, + direction: SortDirection, + ) -> Result, RepositoryError> { + let request = ListRequest::show_all(sort_key, direction); + let page = self.list(&request).await?; + Ok(page.items) + } +} + +#[async_trait] +pub trait RoastRepository: Send + Sync { + async fn insert(&self, roast: Roast) -> Result; + async fn get(&self, id: String) -> Result; + async fn list( + &self, + request: &ListRequest, + ) -> Result, RepositoryError>; + async fn list_by_roaster( + &self, + roaster_id: String, + ) -> Result, RepositoryError>; + async fn update(&self, id: String, changes: UpdateRoast) -> Result; + async fn delete(&self, id: String) -> Result<(), RepositoryError>; + + async fn list_all(&self) -> Result, RepositoryError> { + let sort_key = ::default(); + let request = ListRequest::::show_all(sort_key, sort_key.default_direction()); + let page = self.list(&request).await?; + Ok(page.items) + } +} + +#[async_trait] +pub trait TimelineEventRepository: Send + Sync { + async fn list_all(&self) -> Result, RepositoryError>; +} diff --git a/src/domain/roasters.rs b/src/domain/roasters.rs new file mode 100644 index 0000000..1d6579c --- /dev/null +++ b/src/domain/roasters.rs @@ -0,0 +1,108 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::domain::ids::generate_id; +use crate::domain::listing::{SortDirection, SortKey}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Roaster { + pub id: String, + pub name: String, + pub country: String, + pub city: Option, + pub homepage: Option, + pub notes: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NewRoaster { + pub name: String, + pub country: String, + pub city: Option, + pub homepage: Option, + pub notes: Option, +} + +impl NewRoaster { + pub fn normalize(mut self) -> Self { + self.name = self.name.trim().to_string(); + self.country = self.country.trim().to_string(); + self.city = normalize_optional_field(self.city); + self.homepage = normalize_optional_field(self.homepage); + self.notes = normalize_optional_field(self.notes); + self + } + + pub fn into_roaster(self) -> Roaster { + Roaster { + id: generate_id(), + name: self.name, + country: self.country, + city: self.city, + homepage: self.homepage, + notes: self.notes, + created_at: Utc::now(), + } + } +} + +fn normalize_optional_field(value: Option) -> Option { + value.and_then(|raw| { + let trimmed = raw.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct UpdateRoaster { + pub name: Option, + pub country: Option, + pub city: Option, + pub homepage: Option, + pub notes: Option, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum RoasterSortKey { + CreatedAt, + Name, + Country, + City, +} + +impl SortKey for RoasterSortKey { + fn default() -> Self { + RoasterSortKey::CreatedAt + } + + fn from_query(value: &str) -> Option { + match value { + "created-at" => Some(RoasterSortKey::CreatedAt), + "name" => Some(RoasterSortKey::Name), + "country" => Some(RoasterSortKey::Country), + "city" => Some(RoasterSortKey::City), + _ => None, + } + } + + fn query_value(self) -> &'static str { + match self { + RoasterSortKey::CreatedAt => "created-at", + RoasterSortKey::Name => "name", + RoasterSortKey::Country => "country", + RoasterSortKey::City => "city", + } + } + + fn default_direction(self) -> SortDirection { + match self { + RoasterSortKey::CreatedAt => SortDirection::Desc, + _ => SortDirection::Asc, + } + } +} diff --git a/src/domain/roasts.rs b/src/domain/roasts.rs new file mode 100644 index 0000000..f1da904 --- /dev/null +++ b/src/domain/roasts.rs @@ -0,0 +1,105 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::domain::ids::generate_id; +use crate::domain::listing::{SortDirection, SortKey}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Roast { + pub id: String, + pub roaster_id: String, + pub name: String, + pub origin: Option, + pub region: Option, + pub producer: Option, + pub tasting_notes: Vec, + pub process: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RoastWithRoaster { + pub roast: Roast, + pub roaster_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NewRoast { + pub roaster_id: String, + pub name: String, + pub origin: Option, + pub region: Option, + pub producer: Option, + pub tasting_notes: Vec, + pub process: Option, +} + +impl NewRoast { + pub fn into_roast(self) -> Roast { + Roast { + id: generate_id(), + roaster_id: self.roaster_id, + name: self.name, + origin: self.origin, + region: self.region, + producer: self.producer, + tasting_notes: self.tasting_notes, + process: self.process, + created_at: Utc::now(), + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct UpdateRoast { + pub roaster_id: Option, + pub name: Option, + pub origin: Option, + pub region: Option, + pub producer: Option, + pub tasting_notes: Option>, + pub process: Option, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum RoastSortKey { + CreatedAt, + Name, + Roaster, + Origin, + Producer, +} + +impl SortKey for RoastSortKey { + fn default() -> Self { + RoastSortKey::CreatedAt + } + + fn from_query(value: &str) -> Option { + match value { + "created-at" => Some(RoastSortKey::CreatedAt), + "name" => Some(RoastSortKey::Name), + "roaster" => Some(RoastSortKey::Roaster), + "origin" => Some(RoastSortKey::Origin), + "producer" => Some(RoastSortKey::Producer), + _ => None, + } + } + + fn query_value(self) -> &'static str { + match self { + RoastSortKey::CreatedAt => "created-at", + RoastSortKey::Name => "name", + RoastSortKey::Roaster => "roaster", + RoastSortKey::Origin => "origin", + RoastSortKey::Producer => "producer", + } + } + + fn default_direction(self) -> SortDirection { + match self { + RoastSortKey::CreatedAt => SortDirection::Desc, + _ => SortDirection::Asc, + } + } +} diff --git a/src/domain/timeline.rs b/src/domain/timeline.rs new file mode 100644 index 0000000..312c836 --- /dev/null +++ b/src/domain/timeline.rs @@ -0,0 +1,29 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimelineEventDetail { + pub label: String, + pub value: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimelineEvent { + pub id: String, + pub entity_type: String, + pub entity_id: String, + pub occurred_at: DateTime, + pub title: String, + pub details: Vec, + pub tasting_notes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NewTimelineEvent { + pub entity_type: String, + pub entity_id: String, + pub occurred_at: DateTime, + pub title: String, + pub details: Vec, + pub tasting_notes: Vec, +} diff --git a/src/infrastructure/database.rs b/src/infrastructure/database.rs new file mode 100644 index 0000000..711e0b0 --- /dev/null +++ b/src/infrastructure/database.rs @@ -0,0 +1,81 @@ +use anyhow::Context; +use sqlx::migrate::Migrator; + +#[cfg(all(feature = "sqlite", feature = "postgres"))] +compile_error!("features `sqlite` and `postgres` cannot both be enabled"); + +#[cfg(not(any(feature = "sqlite", feature = "postgres")))] +compile_error!("enable either the `sqlite` or `postgres` feature"); + +#[cfg(feature = "sqlite")] +pub type DatabasePool = sqlx::SqlitePool; +#[cfg(feature = "sqlite")] +type PoolOptions = sqlx::sqlite::SqlitePoolOptions; +#[cfg(feature = "sqlite")] +pub type DatabaseTransaction<'a> = sqlx::Transaction<'a, sqlx::Sqlite>; + +#[cfg(feature = "postgres")] +pub type DatabasePool = sqlx::PgPool; +#[cfg(feature = "postgres")] +type PoolOptions = sqlx::postgres::PgPoolOptions; +#[cfg(feature = "postgres")] +pub type DatabaseTransaction<'a> = sqlx::Transaction<'a, sqlx::Postgres>; + +pub struct Database { + pool: DatabasePool, +} + +impl Database { + pub async fn connect(database_url: &str) -> anyhow::Result { + #[cfg(feature = "sqlite")] + let pool = { + use sqlx::sqlite::SqliteConnectOptions; + use std::str::FromStr; + + let options = SqliteConnectOptions::from_str(database_url) + .with_context(|| format!("invalid database url: {database_url}"))? + .create_if_missing(true); + + PoolOptions::new() + .max_connections(5) + .connect_with(options) + .await + .with_context(|| format!("failed to connect to database: {database_url}"))? + }; + + #[cfg(feature = "postgres")] + let pool = PoolOptions::new() + .max_connections(5) + .connect(database_url) + .await + .with_context(|| format!("failed to connect to database: {database_url}"))?; + + #[cfg(feature = "sqlite")] + { + sqlx::query("PRAGMA foreign_keys = ON;") + .execute(&pool) + .await + .context("failed to enable foreign keys for sqlite")?; + } + + let db = Self { pool }; + db.migrate().await?; + Ok(db) + } + + pub fn pool(&self) -> &DatabasePool { + &self.pool + } + + pub fn clone_pool(&self) -> DatabasePool { + self.pool.clone() + } + + pub async fn migrate(&self) -> anyhow::Result<()> { + static MIGRATOR: Migrator = sqlx::migrate!("./migrations"); + MIGRATOR + .run(&self.pool) + .await + .context("database migration failed") + } +} diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs new file mode 100644 index 0000000..e607629 --- /dev/null +++ b/src/infrastructure/mod.rs @@ -0,0 +1,2 @@ +pub mod database; +pub mod repositories; diff --git a/src/infrastructure/repositories/mod.rs b/src/infrastructure/repositories/mod.rs new file mode 100644 index 0000000..db0bc1f --- /dev/null +++ b/src/infrastructure/repositories/mod.rs @@ -0,0 +1,3 @@ +pub mod roasters; +pub mod roasts; +pub mod timeline_events; diff --git a/src/infrastructure/repositories/roasters.rs b/src/infrastructure/repositories/roasters.rs new file mode 100644 index 0000000..5ed5ab3 --- /dev/null +++ b/src/infrastructure/repositories/roasters.rs @@ -0,0 +1,314 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::{QueryBuilder, query_as, query_scalar}; + +use crate::domain::RepositoryError; +use crate::domain::ids::generate_id; +use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection}; +use crate::domain::repositories::RoasterRepository; +use crate::domain::roasters::{Roaster, RoasterSortKey, UpdateRoaster}; +use crate::domain::timeline::TimelineEventDetail; +use crate::infrastructure::database::DatabasePool; + +type DbId = String; + +#[derive(Clone)] +pub struct SqlRoasterRepository { + pool: DatabasePool, +} + +impl SqlRoasterRepository { + pub fn new(pool: DatabasePool) -> Self { + Self { pool } + } + + fn to_domain(record: RoasterRecord) -> Result { + let RoasterRecord { + id, + name, + country, + city, + homepage, + notes, + created_at, + } = record; + + Ok(Roaster { + id, + name, + country, + city, + homepage, + notes, + created_at, + }) + } +} + +fn roaster_order_clause(request: &ListRequest) -> String { + let dir_sql = match request.sort_direction() { + SortDirection::Asc => "ASC", + SortDirection::Desc => "DESC", + }; + + match request.sort_key() { + RoasterSortKey::CreatedAt => format!("created_at {dir_sql}, name ASC"), + RoasterSortKey::Name => format!("LOWER(name) {dir_sql}, created_at DESC"), + RoasterSortKey::Country => format!("LOWER(country) {dir_sql}, LOWER(name) ASC"), + RoasterSortKey::City => { + format!("LOWER(COALESCE(city, '')) {dir_sql}, LOWER(name) ASC") + } + } +} + +#[async_trait] +impl RoasterRepository for SqlRoasterRepository { + async fn insert(&self, roaster: Roaster) -> Result { + let mut tx = self + .pool + .begin() + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + let query = "INSERT INTO roasters (id, name, country, city, homepage, notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"; + + sqlx::query(query) + .bind(&roaster.id) + .bind(&roaster.name) + .bind(&roaster.country) + .bind(&roaster.city) + .bind(&roaster.homepage) + .bind(&roaster.notes) + .bind(roaster.created_at) + .execute(&mut *tx) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + let homepage_value = roaster + .homepage + .as_ref() + .filter(|value| !value.is_empty()) + .cloned() + .unwrap_or_else(|| "—".to_string()); + + let details = vec![ + TimelineEventDetail { + label: "Country".to_string(), + value: roaster.country.clone(), + }, + TimelineEventDetail { + label: "City".to_string(), + value: roaster + .city + .as_ref() + .filter(|value| !value.is_empty()) + .cloned() + .unwrap_or_else(|| "—".to_string()), + }, + TimelineEventDetail { + label: "Homepage".to_string(), + value: homepage_value, + }, + ]; + + let details_json = serde_json::to_string(&details).map_err(|err| { + RepositoryError::unexpected(format!("failed to encode timeline event details: {err}")) + })?; + + sqlx::query("INSERT INTO timeline_events (id, entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json) VALUES (?, ?, ?, ?, ?, ?, ?)") + .bind(generate_id()) + .bind("roaster") + .bind(&roaster.id) + .bind(roaster.created_at) + .bind(&roaster.name) + .bind(details_json) + .bind(Option::::None) + .execute(&mut *tx) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + tx.commit() + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + Ok(roaster) + } + + async fn get(&self, id: String) -> Result { + let record = query_as::<_, RoasterRecord>( + "SELECT id, name, country, city, homepage, notes, created_at FROM roasters WHERE id = ?", + ) + .bind(id) + .fetch_optional(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + match record { + Some(record) => Self::to_domain(record), + None => Err(RepositoryError::NotFound), + } + } + + async fn list( + &self, + request: &ListRequest, + ) -> Result, RepositoryError> { + let order_clause = roaster_order_clause(request); + + match request.page_size() { + PageSize::All => { + let query = format!( + "SELECT id, name, country, city, homepage, notes, created_at FROM roasters ORDER BY {}", + order_clause + ); + + let records = query_as::<_, RoasterRecord>(&query) + .fetch_all(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + let items = records + .into_iter() + .map(Self::to_domain) + .collect::, _>>()?; + + let total = items.len() as u64; + let page_size = total.min(u64::from(u32::MAX)) as u32; + Ok(Page::new(items, 1, page_size.max(1), total, true)) + } + PageSize::Limited(page_size) => { + let page_size_i64 = page_size as i64; + let mut page = request.page(); + let offset = ((page - 1) as i64).saturating_mul(page_size_i64); + + let query = format!( + "SELECT id, name, country, city, homepage, notes, created_at FROM roasters ORDER BY {} LIMIT ? OFFSET ?", + order_clause + ); + + let mut records = query_as::<_, RoasterRecord>(&query) + .bind(page_size_i64) + .bind(offset) + .fetch_all(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + let total: i64 = query_scalar::<_, i64>("SELECT COUNT(*) FROM roasters") + .fetch_one(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + if page > 1 && records.is_empty() && total > 0 { + let last_page = ((total + page_size_i64 - 1) / page_size_i64) as u32; + page = last_page.max(1); + let offset = ((page - 1) as i64).saturating_mul(page_size_i64); + records = query_as::<_, RoasterRecord>(&query) + .bind(page_size_i64) + .bind(offset) + .fetch_all(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + } + + let items = records + .into_iter() + .map(Self::to_domain) + .collect::, _>>()?; + + Ok(Page::new(items, page, page_size, total as u64, false)) + } + } + } + + async fn update(&self, id: String, changes: UpdateRoaster) -> Result { + let mut builder = QueryBuilder::new("UPDATE roasters SET "); + let mut first = true; + + if let Some(name) = changes.name { + if !first { + builder.push(", "); + } + first = false; + builder.push("name = "); + builder.push_bind(name); + } + if let Some(country) = changes.country { + if !first { + builder.push(", "); + } + first = false; + builder.push("country = "); + builder.push_bind(country); + } + if let Some(city) = changes.city { + if !first { + builder.push(", "); + } + first = false; + builder.push("city = "); + builder.push_bind(city); + } + if let Some(homepage) = changes.homepage { + if !first { + builder.push(", "); + } + first = false; + builder.push("homepage = "); + builder.push_bind(homepage); + } + if let Some(notes) = changes.notes { + if !first { + builder.push(", "); + } + first = false; + builder.push("notes = "); + builder.push_bind(notes); + } + + if first { + return Err(RepositoryError::unexpected( + "No fields provided for update".to_string(), + )); + } + + builder.push(" WHERE id = "); + builder.push_bind(&id); + + let result = builder + .build() + .execute(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + if result.rows_affected() == 0 { + return Err(RepositoryError::NotFound); + } + + self.get(id).await + } + + async fn delete(&self, id: String) -> Result<(), RepositoryError> { + let result = sqlx::query("DELETE FROM roasters WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + if result.rows_affected() == 0 { + return Err(RepositoryError::NotFound); + } + + Ok(()) + } +} + +#[derive(Debug, sqlx::FromRow)] +struct RoasterRecord { + id: DbId, + name: String, + country: String, + city: Option, + homepage: Option, + notes: Option, + created_at: DateTime, +} diff --git a/src/infrastructure/repositories/roasts.rs b/src/infrastructure/repositories/roasts.rs new file mode 100644 index 0000000..d811d2a --- /dev/null +++ b/src/infrastructure/repositories/roasts.rs @@ -0,0 +1,479 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::{Error as SqlxError, QueryBuilder, query, query_as, query_scalar}; + +use crate::domain::RepositoryError; +use crate::domain::ids::generate_id; +use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection}; +use crate::domain::repositories::RoastRepository; +use crate::domain::roasts::{Roast, RoastSortKey, RoastWithRoaster, UpdateRoast}; +use crate::domain::timeline::TimelineEventDetail; +use crate::infrastructure::database::DatabasePool; + +#[derive(Clone)] +pub struct SqlRoastRepository { + pool: DatabasePool, +} + +impl SqlRoastRepository { + pub fn new(pool: DatabasePool) -> Self { + Self { pool } + } + + fn to_domain(record: RoastRecord) -> Result { + let RoastRecord { + id, + roaster_id, + name, + origin, + region, + producer, + process, + tasting_notes, + created_at, + } = record; + + let tasting_notes = match tasting_notes { + Some(raw) if !raw.is_empty() => serde_json::from_str(&raw).map_err(|err| { + RepositoryError::unexpected(format!("failed to decode tasting notes: {err}")) + })?, + _ => Vec::new(), + }; + + Ok(Roast { + id, + roaster_id, + name, + origin, + region, + producer, + tasting_notes, + process, + created_at, + }) + } + + fn to_with_roaster( + record: RoastWithRoasterRecord, + ) -> Result { + let RoastWithRoasterRecord { + id, + roaster_id, + name, + origin, + region, + producer, + process, + tasting_notes, + created_at, + roaster_name, + } = record; + + let roast = Self::to_domain(RoastRecord { + id, + roaster_id, + name, + origin, + region, + producer, + process, + tasting_notes, + created_at, + })?; + + Ok(RoastWithRoaster { + roast, + roaster_name, + }) + } + + async fn get_record(&self, id: &str) -> Result { + let record = query_as::<_, RoastRecord>( + "SELECT id, roaster_id, name, origin, region, producer, process, tasting_notes, created_at FROM roasts WHERE id = ?", + ) + .bind(id) + .fetch_optional(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + let Some(record) = record else { + return Err(RepositoryError::NotFound); + }; + + Self::to_domain(record) + } + + fn encode_notes(notes: &[String]) -> Result, RepositoryError> { + if notes.is_empty() { + Ok(None) + } else { + serde_json::to_string(notes).map(Some).map_err(|err| { + RepositoryError::unexpected(format!("failed to encode tasting notes: {err}")) + }) + } + } +} + +fn roast_order_clause(request: &ListRequest) -> String { + let dir_sql = match request.sort_direction() { + SortDirection::Asc => "ASC", + SortDirection::Desc => "DESC", + }; + + match request.sort_key() { + RoastSortKey::CreatedAt => format!("r.created_at {dir_sql}, LOWER(r.name) ASC"), + RoastSortKey::Name => format!("LOWER(r.name) {dir_sql}, r.created_at DESC"), + RoastSortKey::Roaster => format!("LOWER(ro.name) {dir_sql}, r.created_at DESC"), + RoastSortKey::Origin => { + format!("LOWER(COALESCE(r.origin, '')) {dir_sql}, r.created_at DESC") + } + RoastSortKey::Producer => { + format!("LOWER(COALESCE(r.producer, '')) {dir_sql}, r.created_at DESC") + } + } +} + +#[async_trait] +impl RoastRepository for SqlRoastRepository { + async fn insert(&self, roast: Roast) -> Result { + let mut tx = self + .pool + .begin() + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + let notes = Self::encode_notes(&roast.tasting_notes)?; + + query( + "INSERT INTO roasts (id, roaster_id, name, origin, region, producer, process, tasting_notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&roast.id) + .bind(&roast.roaster_id) + .bind(&roast.name) + .bind(&roast.origin) + .bind(&roast.region) + .bind(&roast.producer) + .bind(&roast.process) + .bind(notes.as_deref()) + .bind(roast.created_at) + .execute(&mut *tx) + .await + .map_err(|err| map_insert_error(err, "unknown roaster reference"))?; + + let roaster_name: Option = + sqlx::query_scalar("SELECT name FROM roasters WHERE id = ?") + .bind(&roast.roaster_id) + .fetch_optional(&mut *tx) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + let roaster_label = roaster_name.unwrap_or_else(|| "Unknown roaster".to_string()); + + let details = vec![ + TimelineEventDetail { + label: "Roaster".to_string(), + value: roaster_label.clone(), + }, + TimelineEventDetail { + label: "Origin".to_string(), + value: roast.origin.clone().unwrap_or_else(|| "—".to_string()), + }, + TimelineEventDetail { + label: "Region".to_string(), + value: roast.region.clone().unwrap_or_else(|| "—".to_string()), + }, + TimelineEventDetail { + label: "Producer".to_string(), + value: roast.producer.clone().unwrap_or_else(|| "—".to_string()), + }, + TimelineEventDetail { + label: "Process".to_string(), + value: roast.process.clone().unwrap_or_else(|| "—".to_string()), + }, + ]; + + let details_json = serde_json::to_string(&details).map_err(|err| { + RepositoryError::unexpected(format!("failed to encode timeline event details: {err}")) + })?; + + let tasting_notes_json = if roast.tasting_notes.is_empty() { + None + } else { + Some(serde_json::to_string(&roast.tasting_notes).map_err(|err| { + RepositoryError::unexpected(format!( + "failed to encode timeline event tasting notes: {err}" + )) + })?) + }; + + sqlx::query("INSERT INTO timeline_events (id, entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json) VALUES (?, ?, ?, ?, ?, ?, ?)") + .bind(generate_id()) + .bind("roast") + .bind(&roast.id) + .bind(roast.created_at) + .bind(&roast.name) + .bind(details_json) + .bind(tasting_notes_json) + .execute(&mut *tx) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + tx.commit() + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + Ok(roast) + } + + async fn get(&self, id: String) -> Result { + self.get_record(&id).await + } + + async fn list( + &self, + request: &ListRequest, + ) -> Result, RepositoryError> { + let order_clause = roast_order_clause(request); + + match request.page_size() { + PageSize::All => { + let query = format!( + "SELECT r.id, r.roaster_id, r.name, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name \ + FROM roasts r \ + JOIN roasters ro ON ro.id = r.roaster_id \ + ORDER BY {}", + order_clause + ); + + let records = query_as::<_, RoastWithRoasterRecord>(&query) + .fetch_all(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + let items = records + .into_iter() + .map(Self::to_with_roaster) + .collect::, _>>()?; + + let total = items.len() as u64; + let page_size = total.min(u64::from(u32::MAX)) as u32; + Ok(Page::new(items, 1, page_size.max(1), total, true)) + } + PageSize::Limited(page_size) => { + let page_size_i64 = page_size as i64; + let mut page = request.page(); + let offset = ((page - 1) as i64).saturating_mul(page_size_i64); + + let query = format!( + "SELECT r.id, r.roaster_id, r.name, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name \ + FROM roasts r \ + JOIN roasters ro ON ro.id = r.roaster_id \ + ORDER BY {} \ + LIMIT ? OFFSET ?", + order_clause + ); + + let mut records = query_as::<_, RoastWithRoasterRecord>(&query) + .bind(page_size_i64) + .bind(offset) + .fetch_all(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + let total: i64 = query_scalar::<_, i64>("SELECT COUNT(*) FROM roasts") + .fetch_one(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + if page > 1 && records.is_empty() && total > 0 { + let last_page = ((total + page_size_i64 - 1) / page_size_i64) as u32; + page = last_page.max(1); + let offset = ((page - 1) as i64).saturating_mul(page_size_i64); + records = query_as::<_, RoastWithRoasterRecord>(&query) + .bind(page_size_i64) + .bind(offset) + .fetch_all(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + } + + let items = records + .into_iter() + .map(Self::to_with_roaster) + .collect::, _>>()?; + + Ok(Page::new(items, page, page_size, total as u64, false)) + } + } + } + + async fn list_by_roaster( + &self, + roaster_id: String, + ) -> Result, RepositoryError> { + let records = query_as::<_, RoastWithRoasterRecord>( + "SELECT r.id, r.roaster_id, r.name, r.origin, r.region, r.producer, r.process, r.tasting_notes, r.created_at, ro.name AS roaster_name \ + FROM roasts r \ + JOIN roasters ro ON ro.id = r.roaster_id \ + WHERE r.roaster_id = ? \ + ORDER BY r.created_at DESC", + ) + .bind(roaster_id) + .fetch_all(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + records.into_iter().map(Self::to_with_roaster).collect() + } + + async fn update(&self, id: String, changes: UpdateRoast) -> Result { + let mut tx = self + .pool + .begin() + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + let UpdateRoast { + roaster_id, + name, + origin, + region, + producer, + tasting_notes, + process, + } = changes; + + let mut builder = QueryBuilder::new("UPDATE roasts SET "); + let mut updated = false; + + if let Some(roaster_id) = roaster_id { + if updated { + builder.push(", "); + } + updated = true; + builder.push("roaster_id = "); + builder.push_bind(roaster_id); + } + if let Some(name) = name { + if updated { + builder.push(", "); + } + updated = true; + builder.push("name = "); + builder.push_bind(name); + } + if let Some(origin) = origin { + if updated { + builder.push(", "); + } + updated = true; + builder.push("origin = "); + builder.push_bind(origin); + } + if let Some(region) = region { + if updated { + builder.push(", "); + } + updated = true; + builder.push("region = "); + builder.push_bind(region); + } + if let Some(producer) = producer { + if updated { + builder.push(", "); + } + updated = true; + builder.push("producer = "); + builder.push_bind(producer); + } + if let Some(process) = process { + if updated { + builder.push(", "); + } + updated = true; + builder.push("process = "); + builder.push_bind(process); + } + if let Some(tasting_notes) = tasting_notes { + let notes = Self::encode_notes(&tasting_notes)?; + if updated { + builder.push(", "); + } + updated = true; + builder.push("tasting_notes = "); + builder.push_bind(notes); + } + + if updated { + builder.push(" WHERE id = "); + builder.push_bind(&id); + + let result = builder + .build() + .execute(&mut *tx) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + if result.rows_affected() == 0 { + return Err(RepositoryError::NotFound); + } + } + + tx.commit() + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + self.get_record(&id).await + } + + async fn delete(&self, id: String) -> Result<(), RepositoryError> { + let result = query("DELETE FROM roasts WHERE id = ?") + .bind(&id) + .execute(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + if result.rows_affected() == 0 { + return Err(RepositoryError::NotFound); + } + + Ok(()) + } +} + +fn map_insert_error(err: SqlxError, message: &'static str) -> RepositoryError { + if let SqlxError::Database(db_err) = &err { + if db_err.code().as_deref() == Some("787") { + return RepositoryError::conflict(message); + } + } + + RepositoryError::unexpected(err.to_string()) +} + +#[derive(sqlx::FromRow)] +struct RoastRecord { + id: String, + roaster_id: String, + name: String, + origin: Option, + region: Option, + producer: Option, + process: Option, + tasting_notes: Option, + created_at: DateTime, +} + +#[derive(sqlx::FromRow)] +struct RoastWithRoasterRecord { + id: String, + roaster_id: String, + name: String, + origin: Option, + region: Option, + producer: Option, + process: Option, + tasting_notes: Option, + created_at: DateTime, + roaster_name: String, +} diff --git a/src/infrastructure/repositories/timeline_events.rs b/src/infrastructure/repositories/timeline_events.rs new file mode 100644 index 0000000..27214a3 --- /dev/null +++ b/src/infrastructure/repositories/timeline_events.rs @@ -0,0 +1,84 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde_json::from_str; +use sqlx::query_as; + +use crate::domain::RepositoryError; +use crate::domain::repositories::TimelineEventRepository; +use crate::domain::timeline::{TimelineEvent, TimelineEventDetail}; +use crate::infrastructure::database::DatabasePool; + +#[derive(Clone)] +pub struct SqlTimelineEventRepository { + pool: DatabasePool, +} + +impl SqlTimelineEventRepository { + pub fn new(pool: DatabasePool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl TimelineEventRepository for SqlTimelineEventRepository { + async fn list_all(&self) -> Result, RepositoryError> { + let records = query_as::<_, TimelineEventRecord>( + "SELECT id, entity_type, entity_id, occurred_at, title, details_json, tasting_notes_json FROM timeline_events ORDER BY occurred_at DESC", + ) + .fetch_all(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + let mut events = Vec::with_capacity(records.len()); + for record in records { + events.push(record.into_domain()?); + } + + Ok(events) + } +} + +#[derive(sqlx::FromRow)] +struct TimelineEventRecord { + id: String, + entity_type: String, + entity_id: String, + occurred_at: DateTime, + title: String, + details_json: Option, + tasting_notes_json: Option, +} + +impl TimelineEventRecord { + fn into_domain(self) -> Result { + let details = match self.details_json { + Some(raw) if !raw.is_empty() => { + from_str::>(&raw).map_err(|err| { + RepositoryError::unexpected(format!( + "failed to decode timeline event details: {err}" + )) + })? + } + _ => Vec::new(), + }; + + let tasting_notes = match self.tasting_notes_json { + Some(raw) if !raw.is_empty() => from_str::>(&raw).map_err(|err| { + RepositoryError::unexpected(format!( + "failed to decode timeline event tasting notes: {err}" + )) + })?, + _ => Vec::new(), + }; + + Ok(TimelineEvent { + id: self.id, + entity_type: self.entity_type, + entity_id: self.entity_id, + occurred_at: self.occurred_at, + title: self.title, + details, + tasting_notes, + }) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..a27af4e --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,6 @@ +pub mod cli; +pub mod client; +pub mod domain; +pub mod infrastructure; +pub mod presentation; +pub mod server; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..394e1a7 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,56 @@ +use anyhow::Result; +use brewlog::cli::{Cli, Commands, ServeCommand, roasters, roasts}; +use brewlog::client::BrewlogClient; +use brewlog::server::{ServerConfig, serve}; +use clap::Parser; +use tracing_subscriber::{EnvFilter, fmt, prelude::*}; + +#[tokio::main] +async fn main() -> Result<()> { + if let Err(err) = init_tracing() { + eprintln!("failed to initialize tracing: {err}"); + } + + let cli = Cli::parse(); + + match cli.command { + Commands::Serve(cmd) => run_server(cmd).await, + command => { + let client = BrewlogClient::from_base_url(&cli.api_url)?; + match command { + // Roasters + Commands::AddRoaster(cmd) => roasters::add_roaster(&client, cmd).await, + Commands::ListRoasters => roasters::list_roasters(&client).await, + Commands::GetRoaster(cmd) => roasters::get_roaster(&client, cmd).await, + Commands::UpdateRoaster(cmd) => roasters::update_roaster(&client, cmd).await, + Commands::DeleteRoaster(cmd) => roasters::delete_roaster(&client, cmd).await, + + // Roasts + Commands::AddRoast(cmd) => roasts::add_roast(&client, cmd).await, + Commands::ListRoasts(cmd) => roasts::list_roasts(&client, cmd).await, + Commands::GetRoast(cmd) => roasts::get_roast(&client, cmd).await, + Commands::DeleteRoast(cmd) => roasts::delete_roast(&client, cmd).await, + Commands::Serve(_) => unreachable!("serve command handled earlier"), + } + } + } +} + +async fn run_server(command: ServeCommand) -> Result<()> { + let config = ServerConfig { + bind_address: command.bind_address, + database_url: command.database_url, + }; + + serve(config).await +} + +fn init_tracing() -> anyhow::Result<()> { + let env_filter = EnvFilter::try_from_default_env().or_else(|_| EnvFilter::try_new("info"))?; + + tracing_subscriber::registry() + .with(env_filter) + .with(fmt::layer()) + .try_init() + .map_err(|err| anyhow::anyhow!("failed to initialize tracing: {err}")) +} diff --git a/src/presentation/mod.rs b/src/presentation/mod.rs new file mode 100644 index 0000000..0cc5c71 --- /dev/null +++ b/src/presentation/mod.rs @@ -0,0 +1,5 @@ +pub mod templates; +pub mod views; + +pub use templates::*; +pub use views::*; diff --git a/src/presentation/templates.rs b/src/presentation/templates.rs new file mode 100644 index 0000000..53b7d01 --- /dev/null +++ b/src/presentation/templates.rs @@ -0,0 +1,64 @@ +use askama::Template; + +use super::views::{ + ListNavigator, Paginated, RoastView, RoasterOptionView, RoasterView, TimelineMonthView, +}; +use crate::domain::roasters::RoasterSortKey; +use crate::domain::roasts::RoastSortKey; + +#[derive(Template)] +#[template(path = "roasters.html")] +pub struct RoastersTemplate { + pub nav_active: &'static str, + pub roasters: Paginated, + pub navigator: ListNavigator, +} + +#[derive(Template)] +#[template(path = "partials/roaster_list.html")] +pub struct RoasterListTemplate { + pub roasters: Paginated, + pub navigator: ListNavigator, +} + +#[derive(Template)] +#[template(path = "roaster_detail.html")] +pub struct RoasterDetailTemplate { + pub nav_active: &'static str, + pub roaster: RoasterView, + pub roasts: Vec, +} + +#[derive(Template)] +#[template(path = "roasts.html")] +pub struct RoastsTemplate { + pub nav_active: &'static str, + pub roasts: Paginated, + pub roaster_options: Vec, + pub navigator: ListNavigator, +} + +#[derive(Template)] +#[template(path = "roast_detail.html")] +pub struct RoastDetailTemplate { + pub nav_active: &'static str, + pub roast: RoastView, +} + +#[derive(Template)] +#[template(path = "partials/roast_list.html")] +pub struct RoastListTemplate { + pub roasts: Paginated, + pub navigator: ListNavigator, +} + +#[derive(Template)] +#[template(path = "timeline.html")] +pub struct TimelineTemplate { + pub nav_active: &'static str, + pub months: Vec, +} + +pub fn render_template(template: T) -> Result { + template.render() +} diff --git a/src/presentation/views.rs b/src/presentation/views.rs new file mode 100644 index 0000000..ca2f42b --- /dev/null +++ b/src/presentation/views.rs @@ -0,0 +1,433 @@ +use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortKey}; +use crate::domain::roasters::Roaster; +use crate::domain::roasts::{Roast, RoastWithRoaster}; + +pub struct Paginated { + pub items: Vec, + pub page: u32, + pub page_size: u32, + pub total: u64, + pub showing_all: bool, +} + +impl Paginated { + pub fn new(items: Vec, page: u32, page_size: u32, total: u64, showing_all: bool) -> Self { + let page = page.max(1); + let page_size = page_size.max(1); + Self { + items, + page, + page_size, + total, + showing_all, + } + } + + pub fn from_page(page: Page, mut map_item: MapFn) -> Self + where + MapFn: FnMut(U) -> T, + { + let items = page.items.into_iter().map(|item| map_item(item)).collect(); + + Self::new( + items, + page.page, + page.page_size, + page.total, + page.showing_all, + ) + } + + pub fn total_pages(&self) -> u32 { + if self.total == 0 { + 1 + } else if self.showing_all { + 1 + } else { + let page_size = self.page_size as u64; + ((self.total + page_size - 1) / page_size) as u32 + } + } + + pub fn has_previous(&self) -> bool { + !self.showing_all && self.page > 1 + } + + pub fn has_next(&self) -> bool { + !self.showing_all && self.page < self.total_pages() + } + + pub fn previous_page(&self) -> Option { + if self.has_previous() { + Some(self.page - 1) + } else { + None + } + } + + pub fn next_page(&self) -> Option { + if self.has_next() { + Some(self.page + 1) + } else { + None + } + } + + pub fn start_index(&self) -> u64 { + if self.total == 0 { + 0 + } else { + ((self.page - 1) as u64) * self.page_size as u64 + 1 + } + } + + pub fn end_index(&self) -> u64 { + if self.total == 0 { + 0 + } else { + self.start_index() + self.items.len() as u64 - 1 + } + } + + pub fn is_showing_all(&self) -> bool { + self.showing_all + } + + pub fn is_page_size(&self, value: u32) -> bool { + !self.showing_all && self.page_size == value + } + + pub fn page_size_query_value(&self) -> String { + if self.showing_all { + "all".to_string() + } else { + self.page_size.to_string() + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct ListNavigator { + base_path: &'static str, + fragment_path: &'static str, + request: ListRequest, +} + +impl ListNavigator { + pub fn new( + base_path: &'static str, + fragment_path: &'static str, + request: ListRequest, + ) -> Self { + Self { + base_path, + fragment_path, + request, + } + } + + pub const fn request(&self) -> ListRequest { + self.request + } + + pub fn sort_key(&self) -> &'static str { + self.request.sort_key().query_value() + } + + pub fn sort_direction(&self) -> &'static str { + self.request.sort_direction().as_str() + } + + pub fn page(&self) -> u32 { + self.request.page() + } + + pub fn page_size_value(&self) -> String { + self.request.page_size().to_query_value() + } + + pub fn is_showing_all(&self) -> bool { + self.request.page_size().is_all() + } + + pub fn page_href(&self, page: u32) -> String { + self.build_href(self.base_path, self.request.with_page(page)) + } + + pub fn fragment_page_href(&self, page: u32) -> String { + self.build_href(self.fragment_path, self.request.with_page(page)) + } + + pub fn rows_href(&self, value: &str) -> String { + self.build_href(self.base_path, Self::request_for_rows(self.request, value)) + } + + pub fn fragment_rows_href(&self, value: &str) -> String { + self.build_href( + self.fragment_path, + Self::request_for_rows(self.request, value), + ) + } + + pub fn sort_href(&self, key: &str) -> String { + self.build_href(self.base_path, Self::request_for_sort(self.request, key)) + } + + pub fn fragment_sort_href(&self, key: &str) -> String { + self.build_href( + self.fragment_path, + Self::request_for_sort(self.request, key), + ) + } + + pub fn is_sorted_by(&self, key: &str) -> bool { + K::from_query(key) + .map(|candidate| candidate == self.request.sort_key()) + .unwrap_or(false) + } + + pub fn next_sort_dir(&self, key: &str) -> &'static str { + let sort_key = K::from_query(key).unwrap_or_else(K::default); + let direction = if sort_key == self.request.sort_key() { + self.request.sort_direction().opposite() + } else { + sort_key.default_direction() + }; + direction.as_str() + } + + pub fn query(&self) -> String { + Self::query_string(self.request) + } + + pub fn query_for_page(&self, page: u32) -> String { + Self::query_string(self.request.with_page(page)) + } + + pub fn query_for_rows(&self, value: &str) -> String { + Self::query_string(Self::request_for_rows(self.request, value)) + } + + pub fn query_for_sort(&self, key: &str) -> String { + Self::query_string(Self::request_for_sort(self.request, key)) + } + + fn build_href(&self, path: &str, request: ListRequest) -> String { + if let Some((base, fragment)) = path.split_once('#') { + format!("{}?{}#{}", base, Self::query_string(request), fragment) + } else { + format!("{}?{}", path, Self::query_string(request)) + } + } + + fn request_for_rows(request: ListRequest, value: &str) -> ListRequest { + let page_size = page_size_from_text(value); + request.with_page(1).with_page_size(page_size) + } + + fn request_for_sort(request: ListRequest, key: &str) -> ListRequest { + let sort_key = K::from_query(key).unwrap_or_else(K::default); + request.with_page(1).with_sort(sort_key) + } + + fn query_string(request: ListRequest) -> String { + format!( + "page={}&page_size={}&sort={}&dir={}", + request.page(), + request.page_size().to_query_value(), + request.sort_key().query_value(), + request.sort_direction().as_str() + ) + } +} + +fn page_size_from_text(value: &str) -> PageSize { + if value.eq_ignore_ascii_case("all") { + PageSize::All + } else if let Ok(parsed) = value.parse::() { + PageSize::limited(parsed) + } else { + PageSize::limited(DEFAULT_PAGE_SIZE) + } +} + +pub struct RoasterOptionView { + pub id: String, + pub name: String, +} + +impl From for RoasterOptionView { + fn from(roaster: Roaster) -> Self { + Self { + id: roaster.id, + name: roaster.name, + } + } +} + +impl From<&Roaster> for RoasterOptionView { + fn from(roaster: &Roaster) -> Self { + Self { + id: roaster.id.clone(), + name: roaster.name.clone(), + } + } +} + +pub struct RoasterView { + pub id: String, + pub detail_path: String, + pub name: String, + pub country: String, + pub city: String, + pub has_homepage: bool, + pub homepage_url: String, + pub homepage_label: String, + pub notes: String, + pub created_at: String, + pub created_at_sort_key: i64, +} + +impl From for RoasterView { + fn from(roaster: Roaster) -> Self { + let Roaster { + id, + name, + country, + city, + homepage, + notes, + created_at, + } = roaster; + + let homepage = homepage.unwrap_or_default(); + let has_homepage = !homepage.is_empty(); + let detail_path = format!("/roasters/{id}"); + + let created_at_sort_key = created_at.timestamp(); + let created_at_label = created_at.format("%Y-%m-%d").to_string(); + + Self { + detail_path, + id, + name, + country, + city: city.unwrap_or_else(|| "—".to_string()), + has_homepage, + homepage_url: homepage.clone(), + homepage_label: homepage, + notes: notes.unwrap_or_else(|| "This roaster has no notes yet.".to_string()), + created_at: created_at_label, + created_at_sort_key, + } + } +} + +pub struct RoastView { + pub id: String, + pub full_id: String, + pub detail_path: String, + pub name: String, + pub roaster_label: String, + pub origin: String, + pub region: String, + pub producer: String, + pub process: String, + pub created_at: String, + pub created_at_sort_key: i64, + pub tasting_notes: Vec, +} + +impl RoastView { + pub fn from_domain(roast: Roast, roaster_name: &str) -> Self { + Self::from_parts(roast, roaster_name) + } + + pub fn from_list_item(item: RoastWithRoaster) -> Self { + let RoastWithRoaster { + roast, + roaster_name, + } = item; + Self::from_parts(roast, &roaster_name) + } + + fn from_parts(roast: Roast, roaster_name: &str) -> Self { + let Roast { + id: full_id, + roaster_id: _, + name, + origin, + region, + producer, + tasting_notes, + process, + created_at, + } = roast; + + let id: String = full_id.chars().take(6).collect(); + let roaster_label = if roaster_name.trim().is_empty() { + "Unknown roaster".to_string() + } else { + roaster_name.to_string() + }; + let origin = origin.unwrap_or_else(|| "—".to_string()); + let region = region.unwrap_or_else(|| "—".to_string()); + let producer = producer.unwrap_or_else(|| "—".to_string()); + let process = process.unwrap_or_else(|| "—".to_string()); + let created_at_sort_key = created_at.timestamp(); + let tasting_notes = tasting_notes + .into_iter() + .flat_map(|note| { + note.split(|ch| ch == ',' || ch == '\n') + .map(|segment| segment.trim().to_string()) + .filter(|segment| !segment.is_empty()) + .collect::>() + }) + .collect(); + let created_at = created_at.format("%Y-%m-%d").to_string(); + let detail_path = format!("/roasts/{full_id}"); + + Self { + id, + full_id, + detail_path, + name, + roaster_label, + origin, + region, + producer, + process, + created_at, + created_at_sort_key, + tasting_notes, + } + } +} + +pub struct TimelineEventDetailView { + pub label: String, + pub value: String, +} + +pub struct TimelineEventView { + pub id: String, + pub kind_label: &'static str, + pub badge_class: &'static str, + pub accent_class: &'static str, + pub card_border_class: &'static str, + pub title_class: &'static str, + pub date_label: String, + pub time_label: Option, + pub iso_timestamp: String, + pub title: String, + pub link: String, + pub external_link: Option, + pub details: Vec, + pub tasting_notes: Option>, +} + +pub struct TimelineMonthView { + pub anchor: String, + pub heading: String, + pub events: Vec, +} diff --git a/src/server/errors.rs b/src/server/errors.rs new file mode 100644 index 0000000..4b5f350 --- /dev/null +++ b/src/server/errors.rs @@ -0,0 +1,86 @@ +use axum::Json; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tracing::error; + +use crate::domain::RepositoryError; + +#[derive(Debug, Serialize, Deserialize)] +pub struct ErrorResponse { + pub message: String, +} + +impl ErrorResponse { + pub fn new(message: T) -> Self { + Self { + message: message.to_string(), + } + } +} + +pub struct ApiError(AppError); + +impl From for ApiError { + fn from(value: AppError) -> Self { + Self(value) + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let (status, message) = match self.0 { + AppError::Validation(message) => (StatusCode::BAD_REQUEST, message), + AppError::NotFound => (StatusCode::NOT_FOUND, "entity not found".to_string()), + AppError::Unexpected(message) => { + error!(error = %message, "unexpected application error"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "unexpected error".to_string(), + ) + } + }; + + (status, Json(ErrorResponse::new(message))).into_response() + } +} + +pub fn map_app_error(err: AppError) -> StatusCode { + match err { + AppError::Validation(_) => StatusCode::BAD_REQUEST, + AppError::NotFound => StatusCode::NOT_FOUND, + AppError::Unexpected(_) => StatusCode::INTERNAL_SERVER_ERROR, + } +} + +pub type AppResult = Result; + +#[derive(Debug, Error)] +pub enum AppError { + #[error("validation failed: {0}")] + Validation(String), + #[error("entity not found")] + NotFound, + #[error("unexpected error: {0}")] + Unexpected(String), +} + +impl AppError { + pub fn validation(msg: T) -> Self { + Self::Validation(msg.to_string()) + } + + pub fn unexpected(msg: T) -> Self { + Self::Unexpected(msg.to_string()) + } +} +impl From for AppError { + fn from(value: RepositoryError) -> Self { + match value { + RepositoryError::NotFound => Self::NotFound, + RepositoryError::Conflict(msg) => Self::Validation(msg), + RepositoryError::Unexpected(msg) => Self::Unexpected(msg), + } + } +} diff --git a/src/server/mod.rs b/src/server/mod.rs new file mode 100644 index 0000000..f54cb6b --- /dev/null +++ b/src/server/mod.rs @@ -0,0 +1,6 @@ +pub mod errors; +pub mod routes; +pub mod server; + +pub use routes::app_router; +pub use server::{ServerConfig, serve}; diff --git a/src/server/routes/mod.rs b/src/server/routes/mod.rs new file mode 100644 index 0000000..af8de2e --- /dev/null +++ b/src/server/routes/mod.rs @@ -0,0 +1,73 @@ +pub mod roasters; +pub mod roasts; +pub mod support; +pub mod timeline; + +use askama::Template; +use axum::http::StatusCode; +use axum::response::{Html, IntoResponse, Redirect}; +use axum::routing::{get, post}; +use tracing::error; + +use crate::server::server::AppState; + +use crate::presentation::templates::render_template; + +pub fn app_router(state: AppState) -> axum::Router { + let api_routes = axum::Router::new() + .route( + "/roasters", + post(roasters::create_roaster).get(roasters::list_roasters), + ) + .route( + "/roasters/:id", + get(roasters::get_roaster) + .put(roasters::update_roaster) + .delete(roasters::delete_roaster), + ) + .route( + "/roasts", + post(roasts::create_roast).get(roasts::list_roasts), + ) + .route( + "/roasts/:id", + get(roasts::get_roast).delete(roasts::delete_roast), + ); + + axum::Router::new() + .route("/", get(root_redirect)) + .route("/roasters", get(roasters::roasters_page)) + .route("/roasters/:id", get(roasters::roaster_page)) + .route("/roasts", get(roasts::roasts_page)) + .route("/roasts/:id", get(roasts::roast_page)) + .route("/timeline", get(timeline::timeline_page)) + .route("/styles.css", get(styles)) + .route("/favicon.ico", get(favicon)) + .nest("/api/v1", api_routes) + .with_state(state) +} + +async fn root_redirect() -> Redirect { + Redirect::temporary("/timeline") +} + +async fn styles() -> impl IntoResponse { + ( + [("content-type", "text/css; charset=utf-8")], + include_str!("../../../templates/styles.css"), + ) +} + +async fn favicon() -> impl IntoResponse { + ( + [("content-type", "image/x-icon")], + include_bytes!("../../../templates/favicon.ico").as_ref(), + ) +} + +pub(crate) fn render_html(template: T) -> Result, StatusCode> { + render_template(template).map(Html).map_err(|err| { + error!(error = %err, "failed to render template"); + StatusCode::INTERNAL_SERVER_ERROR + }) +} diff --git a/src/server/routes/roasters.rs b/src/server/routes/roasters.rs new file mode 100644 index 0000000..c1a6a8c --- /dev/null +++ b/src/server/routes/roasters.rs @@ -0,0 +1,221 @@ +use axum::Json; +use axum::extract::{Path, Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{Html, IntoResponse, Redirect, Response}; + +use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection}; +use crate::domain::roasters::{NewRoaster, Roaster, RoasterSortKey, UpdateRoaster}; +use crate::presentation::templates::{ + RoasterDetailTemplate, RoasterListTemplate, RoastersTemplate, +}; +use crate::presentation::views::{ListNavigator, Paginated, RoastView, RoasterView}; +use crate::server::errors::{ApiError, AppError, map_app_error}; +use crate::server::routes::render_html; +use crate::server::routes::support::{ + FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, set_datastar_patch_headers, +}; +use crate::server::server::AppState; + +const ROASTER_PAGE_PATH: &str = "/roasters"; +const ROASTER_FRAGMENT_PATH: &str = "/roasters#roaster-list"; + +fn normalize_request( + request: ListRequest, + page: &Page, +) -> ListRequest { + let page_size = if page.showing_all { + PageSize::All + } else { + PageSize::limited(page.page_size) + }; + + ListRequest::new( + page.page, + page_size, + request.sort_key(), + request.sort_direction(), + ) +} + +fn roaster_navigator(request: ListRequest) -> ListNavigator { + ListNavigator::new(ROASTER_PAGE_PATH, ROASTER_FRAGMENT_PATH, request) +} + +async fn load_roaster_page( + state: &AppState, + request: ListRequest, +) -> Result<(Paginated, ListNavigator), AppError> { + let page = state + .roaster_repo + .list(&request) + .await + .map_err(AppError::from)?; + + let normalized_request = normalize_request(request, &page); + let roasters = Paginated::from_page(page, RoasterView::from); + let navigator = roaster_navigator(normalized_request); + + Ok((roasters, navigator)) +} + +pub(crate) async fn roasters_page( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result { + let request = query.into_request::(); + + if is_datastar_request(&headers) { + return render_roaster_list_fragment(state, request) + .await + .map_err(|err| map_app_error(err)); + } + + let (roasters, navigator) = load_roaster_page(&state, request) + .await + .map_err(|err| map_app_error(err))?; + + let template = RoastersTemplate { + nav_active: "roasters", + roasters, + navigator, + }; + + render_html(template).map(IntoResponse::into_response) +} + +pub(crate) async fn roaster_page( + State(state): State, + Path(id): Path, +) -> Result, StatusCode> { + let roaster = state + .roaster_repo + .get(id.clone()) + .await + .map_err(|err| map_app_error(AppError::from(err)))?; + let roasts = state + .roast_repo + .list_by_roaster(id) + .await + .map_err(|err| map_app_error(AppError::from(err)))?; + + let roaster_view = RoasterView::from(roaster); + + let template = RoasterDetailTemplate { + nav_active: "roasters", + roaster: roaster_view, + roasts: roasts.into_iter().map(RoastView::from_list_item).collect(), + }; + + render_html(template) +} + +pub(crate) async fn list_roasters( + State(state): State, +) -> Result>, ApiError> { + let roasters = state + .roaster_repo + .list_all_sorted(RoasterSortKey::Name, SortDirection::Asc) + .await + .map_err(AppError::from)?; + Ok(Json(roasters)) +} + +pub(crate) async fn create_roaster( + State(state): State, + headers: HeaderMap, + Query(query): Query, + payload: FlexiblePayload, +) -> Result { + let request = query.into_request::(); + let (new_roaster, source) = payload.into_parts(); + let roaster = new_roaster.normalize().into_roaster(); + let roaster = state + .roaster_repo + .insert(roaster) + .await + .map_err(AppError::from)?; + + if is_datastar_request(&headers) { + render_roaster_list_fragment(state, request) + .await + .map_err(ApiError::from) + } else if matches!(source, PayloadSource::Form) { + let target = roaster_navigator(request).page_href(1); + Ok(Redirect::to(&target).into_response()) + } else { + Ok((StatusCode::CREATED, Json(roaster)).into_response()) + } +} + +pub(crate) async fn get_roaster( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let roaster = state.roaster_repo.get(id).await.map_err(AppError::from)?; + Ok(Json(roaster)) +} + +pub(crate) async fn update_roaster( + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> Result, ApiError> { + let has_changes = payload.name.is_some() + || payload.country.is_some() + || payload.city.is_some() + || payload.homepage.is_some() + || payload.notes.is_some(); + + if !has_changes { + return Err(AppError::validation("no changes provided").into()); + } + + let roaster = state + .roaster_repo + .update(id, payload) + .await + .map_err(AppError::from)?; + Ok(Json(roaster)) +} + +pub(crate) async fn delete_roaster( + State(state): State, + headers: HeaderMap, + Path(id): Path, + Query(query): Query, +) -> Result { + let request = query.into_request::(); + state + .roaster_repo + .delete(id) + .await + .map_err(AppError::from)?; + + if is_datastar_request(&headers) { + render_roaster_list_fragment(state, request) + .await + .map_err(ApiError::from) + } else { + Ok(StatusCode::NO_CONTENT.into_response()) + } +} + +async fn render_roaster_list_fragment( + state: AppState, + request: ListRequest, +) -> Result { + let (roasters, navigator) = load_roaster_page(&state, request).await?; + + let template = RoasterListTemplate { + roasters, + navigator, + }; + + let html = crate::presentation::templates::render_template(template) + .map_err(|err| AppError::unexpected(format!("failed to render roaster list: {err}")))?; + + let mut response = Html(html).into_response(); + set_datastar_patch_headers(response.headers_mut(), "#roaster-list"); + Ok(response) +} diff --git a/src/server/routes/roasts.rs b/src/server/routes/roasts.rs new file mode 100644 index 0000000..2d25c33 --- /dev/null +++ b/src/server/routes/roasts.rs @@ -0,0 +1,343 @@ +use axum::Json; +use axum::extract::{Path, Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{Html, IntoResponse, Redirect, Response}; +use serde::Deserialize; +use serde::de::{self, Deserializer, SeqAccess, Visitor}; +use std::fmt; + +use crate::domain::listing::{ListRequest, Page, PageSize, SortDirection}; +use crate::domain::roasters::RoasterSortKey; +use crate::domain::roasts::{NewRoast, Roast, RoastSortKey, RoastWithRoaster}; +use crate::presentation::templates::{RoastDetailTemplate, RoastListTemplate, RoastsTemplate}; +use crate::presentation::views::{ListNavigator, Paginated, RoastView, RoasterOptionView}; +use crate::server::errors::{ApiError, AppError, map_app_error}; +use crate::server::routes::render_html; +use crate::server::routes::support::{ + FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, set_datastar_patch_headers, +}; +use crate::server::server::AppState; + +const ROAST_PAGE_PATH: &str = "/roasts"; +const ROAST_FRAGMENT_PATH: &str = "/roasts#roast-list"; + +fn normalize_request( + request: ListRequest, + page: &Page, +) -> ListRequest { + let page_size = if page.showing_all { + PageSize::All + } else { + PageSize::limited(page.page_size) + }; + + ListRequest::new( + page.page, + page_size, + request.sort_key(), + request.sort_direction(), + ) +} + +fn roast_navigator(request: ListRequest) -> ListNavigator { + ListNavigator::new(ROAST_PAGE_PATH, ROAST_FRAGMENT_PATH, request) +} + +async fn load_roast_page( + state: &AppState, + request: ListRequest, +) -> Result<(Paginated, ListNavigator), AppError> { + let page = state + .roast_repo + .list(&request) + .await + .map_err(AppError::from)?; + + let normalized_request = normalize_request(request, &page); + let roasts = Paginated::from_page(page, RoastView::from_list_item); + let navigator = roast_navigator(normalized_request); + + Ok((roasts, navigator)) +} + +pub(crate) async fn roasts_page( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Result { + let request = query.into_request::(); + + if is_datastar_request(&headers) { + return render_roast_list_fragment(state, request) + .await + .map_err(|err| map_app_error(err)); + } + + let roasters = state + .roaster_repo + .list_all_sorted(RoasterSortKey::Name, SortDirection::Asc) + .await + .map_err(|err| map_app_error(AppError::from(err)))?; + + let roaster_options = roasters.into_iter().map(RoasterOptionView::from).collect(); + + let (roasts, navigator) = load_roast_page(&state, request) + .await + .map_err(|err| map_app_error(err))?; + + let template = RoastsTemplate { + nav_active: "roasts", + roasts, + roaster_options, + navigator, + }; + + render_html(template).map(IntoResponse::into_response) +} + +pub(crate) async fn roast_page( + State(state): State, + Path(id): Path, +) -> Result, StatusCode> { + let roast = state + .roast_repo + .get(id.clone()) + .await + .map_err(|err| map_app_error(AppError::from(err)))?; + let roaster = state + .roaster_repo + .get(roast.roaster_id.clone()) + .await + .map_err(|err| map_app_error(AppError::from(err)))?; + + let template = RoastDetailTemplate { + nav_active: "roasts", + roast: RoastView::from_domain(roast, &roaster.name), + }; + + render_html(template) +} + +pub(crate) async fn create_roast( + State(state): State, + headers: HeaderMap, + Query(query): Query, + payload: FlexiblePayload, +) -> Result { + let request = query.into_request::(); + let (submission, source) = payload.into_parts(); + let new_roast = submission.into_new_roast().map_err(ApiError::from)?; + + state + .roaster_repo + .get(new_roast.roaster_id.clone()) + .await + .map_err(|err| ApiError::from(AppError::from(err)))?; + + let roast = new_roast.into_roast(); + let roast = state + .roast_repo + .insert(roast) + .await + .map_err(AppError::from)?; + + if is_datastar_request(&headers) { + render_roast_list_fragment(state, request) + .await + .map_err(ApiError::from) + } else if matches!(source, PayloadSource::Form) { + let target = roast_navigator(request).page_href(1); + Ok(Redirect::to(&target).into_response()) + } else { + Ok((StatusCode::CREATED, Json(roast)).into_response()) + } +} + +pub(crate) async fn list_roasts( + State(state): State, + Query(params): Query, +) -> Result>, ApiError> { + let roasts = match params.roaster_id { + Some(roaster_id) => state + .roast_repo + .list_by_roaster(roaster_id) + .await + .map_err(AppError::from)?, + None => state.roast_repo.list_all().await.map_err(AppError::from)?, + }; + Ok(Json(roasts)) +} + +pub(crate) async fn get_roast( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let roast = state.roast_repo.get(id).await.map_err(AppError::from)?; + Ok(Json(roast)) +} + +pub(crate) async fn delete_roast( + State(state): State, + headers: HeaderMap, + Path(id): Path, + Query(query): Query, +) -> Result { + let request = query.into_request::(); + state.roast_repo.delete(id).await.map_err(AppError::from)?; + + if is_datastar_request(&headers) { + render_roast_list_fragment(state, request) + .await + .map_err(ApiError::from) + } else { + Ok(StatusCode::NO_CONTENT.into_response()) + } +} + +#[derive(Debug, Deserialize)] +pub struct RoastsQuery { + pub roaster_id: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct NewRoastSubmission { + roaster_id: String, + name: String, + #[serde(default)] + origin: Option, + #[serde(default)] + region: Option, + #[serde(default)] + producer: Option, + #[serde(default, deserialize_with = "string_or_vec")] + tasting_notes: Vec, + #[serde(default)] + process: Option, +} + +impl NewRoastSubmission { + fn into_new_roast(self) -> Result { + let roaster_id = self.roaster_id.trim().to_string(); + if roaster_id.is_empty() { + return Err(AppError::validation("roaster is required")); + } + + let name = self.name.trim().to_string(); + if name.is_empty() { + return Err(AppError::validation("name is required")); + } + + Ok(NewRoast { + roaster_id, + name, + origin: trim_optional(self.origin), + region: trim_optional(self.region), + producer: trim_optional(self.producer), + tasting_notes: self + .tasting_notes + .into_iter() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .collect(), + process: trim_optional(self.process), + }) + } +} + +fn trim_optional(value: Option) -> Option { + value.and_then(|raw| { + let trimmed = raw.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +fn string_or_vec<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + struct StringOrVecVisitor; + + impl<'de> Visitor<'de> for StringOrVecVisitor { + type Value = Vec; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a string or sequence of strings") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + let split = value + .split(|ch| ch == ',' || ch == '\n') + .map(|segment| segment.trim().to_string()) + .filter(|segment| !segment.is_empty()) + .collect(); + Ok(split) + } + + fn visit_string(self, value: String) -> Result + where + E: de::Error, + { + self.visit_str(&value) + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let mut values = Vec::new(); + while let Some(value) = seq.next_element::()? { + let trimmed = value.trim(); + if !trimmed.is_empty() { + values.push(trimmed.to_string()); + } + } + Ok(values) + } + + fn visit_unit(self) -> Result + where + E: de::Error, + { + Ok(Vec::new()) + } + + fn visit_none(self) -> Result + where + E: de::Error, + { + Ok(Vec::new()) + } + + fn visit_some(self, deserializer: D2) -> Result + where + D2: Deserializer<'de>, + { + string_or_vec(deserializer) + } + } + + deserializer.deserialize_any(StringOrVecVisitor) +} + +async fn render_roast_list_fragment( + state: AppState, + request: ListRequest, +) -> Result { + let (roasts, navigator) = load_roast_page(&state, request).await?; + + let template = RoastListTemplate { roasts, navigator }; + + let html = crate::presentation::templates::render_template(template) + .map_err(|err| AppError::unexpected(format!("failed to render roast list: {err}")))?; + + let mut response = Html(html).into_response(); + set_datastar_patch_headers(response.headers_mut(), "#roast-list"); + Ok(response) +} diff --git a/src/server/routes/support.rs b/src/server/routes/support.rs new file mode 100644 index 0000000..c9f0f7b --- /dev/null +++ b/src/server/routes/support.rs @@ -0,0 +1,143 @@ +use axum::async_trait; +use axum::extract::{Form, FromRequest, Json as JsonPayload, Request}; +use axum::http::{HeaderMap, HeaderValue, header::CONTENT_TYPE}; +use serde::Deserialize; + +use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, PageSize, SortDirection, SortKey}; +use crate::server::errors::{ApiError, AppError}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PayloadSource { + Json, + Form, +} + +pub struct FlexiblePayload { + inner: T, + source: PayloadSource, +} + +impl FlexiblePayload { + pub fn into_parts(self) -> (T, PayloadSource) { + (self.inner, self.source) + } +} + +#[derive(Debug, Default, Deserialize)] +pub(crate) struct ListQuery { + page: Option, + #[serde(default)] + page_size: Option, + #[serde(default, rename = "sort")] + sort_key: Option, + #[serde(default, rename = "dir")] + sort_dir: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum PageSizeParam { + Number(u32), + Text(String), +} + +impl ListQuery { + pub fn into_request(self) -> ListRequest { + let page = self.page.unwrap_or(1); + let page_size = match self.page_size { + Some(PageSizeParam::Number(value)) => PageSize::limited(value), + Some(PageSizeParam::Text(text)) => page_size_from_text(&text), + None => PageSize::limited(DEFAULT_PAGE_SIZE), + }; + + let sort_key = self + .sort_key + .as_deref() + .and_then(K::from_query) + .unwrap_or_else(K::default); + + let sort_direction = self + .sort_dir + .as_deref() + .and_then(parse_direction) + .unwrap_or_else(|| sort_key.default_direction()); + + ListRequest::new(page, page_size, sort_key, sort_direction) + } +} + +fn page_size_from_text(value: &str) -> PageSize { + if value.eq_ignore_ascii_case("all") { + PageSize::All + } else if let Ok(parsed) = value.parse::() { + PageSize::limited(parsed) + } else { + PageSize::limited(DEFAULT_PAGE_SIZE) + } +} + +fn parse_direction(value: &str) -> Option { + match value.to_ascii_lowercase().as_str() { + "asc" => Some(SortDirection::Asc), + "desc" => Some(SortDirection::Desc), + _ => None, + } +} + +#[async_trait] +impl FromRequest for FlexiblePayload +where + S: Send + Sync, + T: Send + 'static, + JsonPayload: FromRequest, + Form: FromRequest, +{ + type Rejection = ApiError; + + async fn from_request(req: Request, state: &S) -> Result { + let content_type = req + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_ascii_lowercase(); + + if content_type.starts_with("application/json") { + let JsonPayload(payload) = JsonPayload::::from_request(req, state) + .await + .map_err(|_| ApiError::from(AppError::validation("invalid JSON payload")))?; + + return Ok(Self { + inner: payload, + source: PayloadSource::Json, + }); + } + + if content_type.is_empty() || content_type.starts_with("application/x-www-form-urlencoded") + { + let Form(payload) = Form::::from_request(req, state) + .await + .map_err(|_| ApiError::from(AppError::validation("invalid form payload")))?; + + return Ok(Self { + inner: payload, + source: PayloadSource::Form, + }); + } + + Err(AppError::validation("unsupported content type").into()) + } +} + +pub fn is_datastar_request(headers: &HeaderMap) -> bool { + headers + .get("datastar-request") + .and_then(|value| value.to_str().ok()) + .map(|value| value.eq_ignore_ascii_case("true")) + .unwrap_or(false) +} + +pub fn set_datastar_patch_headers(headers: &mut HeaderMap, selector: &'static str) { + let _ = headers.insert("datastar-selector", HeaderValue::from_static(selector)); + let _ = headers.insert("datastar-mode", HeaderValue::from_static("replace")); +} diff --git a/src/server/routes/timeline.rs b/src/server/routes/timeline.rs new file mode 100644 index 0000000..2965ab8 --- /dev/null +++ b/src/server/routes/timeline.rs @@ -0,0 +1,118 @@ +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::Html; + +use crate::presentation::templates::TimelineTemplate; +use crate::presentation::views::{TimelineEventDetailView, TimelineEventView, TimelineMonthView}; +use crate::server::errors::{AppError, map_app_error}; +use crate::server::routes::render_html; +use crate::server::server::AppState; + +pub(crate) async fn timeline_page( + State(state): State, +) -> Result, StatusCode> { + let events = state + .timeline_repo + .list_all() + .await + .map_err(|err| map_app_error(AppError::from(err)))?; + + let mut months: Vec = Vec::new(); + + for event in events { + let occurred_at = event.occurred_at; + let anchor = occurred_at.format("%Y-%m").to_string(); + let heading = occurred_at.format("%B %Y").to_string(); + + let entity_id = event.entity_id.clone(); + let kind_label = match event.entity_type.as_str() { + "roaster" => "Roaster Added", + "roast" => "Roast Added", + _ => "Event", + }; + let link = match event.entity_type.as_str() { + "roaster" => format!("/roasters/{entity_id}"), + "roast" => format!("/roasts/{entity_id}"), + _ => String::from("#"), + }; + + let mut details: Vec = event + .details + .into_iter() + .map(|detail| TimelineEventDetailView { + label: detail.label, + value: detail.value, + }) + .collect(); + + let external_link = if let Some(index) = details + .iter() + .position(|detail| detail.label.eq_ignore_ascii_case("homepage")) + { + let value = details.remove(index).value; + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed == "—" { + None + } else { + Some(trimmed.to_string()) + } + } else { + None + }; + + let tasting_notes = if event.entity_type == "roast" { + let notes = event + .tasting_notes + .clone() + .into_iter() + .flat_map(|note| { + note.split(|ch| ch == ',' || ch == '\n') + .map(|segment| segment.trim()) + .filter(|segment| !segment.is_empty()) + .map(|segment| segment.to_string()) + .collect::>() + }) + .collect::>(); + Some(notes) + } else { + None + }; + + let view = TimelineEventView { + id: event.id, + kind_label, + badge_class: "bg-amber-200 text-amber-800", + accent_class: "bg-amber-600", + card_border_class: "border-amber-200 bg-amber-50/80", + title_class: "text-amber-800", + date_label: occurred_at.format("%B %d, %Y").to_string(), + time_label: Some(occurred_at.format("%H:%M UTC").to_string()), + iso_timestamp: occurred_at.to_rfc3339(), + title: event.title, + link, + external_link, + details, + tasting_notes, + }; + + if let Some(last) = months.last_mut() { + if last.anchor == anchor { + last.events.push(view); + continue; + } + } + + months.push(TimelineMonthView { + anchor, + heading, + events: vec![view], + }); + } + + let template = TimelineTemplate { + nav_active: "timeline", + months, + }; + + render_html(template) +} diff --git a/src/server/server.rs b/src/server/server.rs new file mode 100644 index 0000000..a68207f --- /dev/null +++ b/src/server/server.rs @@ -0,0 +1,90 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use anyhow::Context; +use axum::Router; +use tokio::net::TcpListener; +use tokio::signal; +use tracing::info; + +use crate::domain::repositories::{RoastRepository, RoasterRepository, TimelineEventRepository}; +use crate::infrastructure::database::Database; +use crate::infrastructure::repositories::roasters::SqlRoasterRepository; +use crate::infrastructure::repositories::roasts::SqlRoastRepository; +use crate::infrastructure::repositories::timeline_events::SqlTimelineEventRepository; +use crate::server::routes::app_router; + +pub struct ServerConfig { + pub bind_address: SocketAddr, + pub database_url: String, +} + +#[derive(Clone)] +pub struct AppState { + pub roaster_repo: Arc, + pub roast_repo: Arc, + pub timeline_repo: Arc, +} + +impl AppState { + pub fn new( + roaster_repo: Arc, + roast_repo: Arc, + timeline_repo: Arc, + ) -> Self { + Self { + roaster_repo, + roast_repo, + timeline_repo, + } + } +} + +pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { + let database = Database::connect(&config.database_url) + .await + .context("failed to connect to database")?; + database.migrate().await?; + + let roaster_repo = Arc::new(SqlRoasterRepository::new(database.clone_pool())); + let roast_repo = Arc::new(SqlRoastRepository::new(database.clone_pool())); + let timeline_repo = Arc::new(SqlTimelineEventRepository::new(database.clone_pool())); + let state = AppState::new(roaster_repo, roast_repo, timeline_repo); + + let listener = TcpListener::bind(config.bind_address) + .await + .with_context(|| format!("failed to bind to {}", config.bind_address))?; + + let app: Router = app_router(state); + + info!(address = %config.bind_address, "starting HTTP server"); + + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await + .context("server terminated unexpectedly")?; + + info!("server shutdown complete"); + + Ok(()) +} + +async fn shutdown_signal() { + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to install SIGTERM handler") + .recv() + .await; + }; + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } +} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..a523062 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,23 @@ + + + + + + {% block title %}Brewlog{% endblock %} + + + + {% block head %}{% endblock %} + + +
+ {% include "nav.html" %} {% block content %}{% endblock %} +
+

Built with Axum, Tailwind CSS, and Datastar.

+
+
+ + diff --git a/templates/favicon.ico b/templates/favicon.ico new file mode 100644 index 0000000..6185bea Binary files /dev/null and b/templates/favicon.ico differ diff --git a/templates/nav.html b/templates/nav.html new file mode 100644 index 0000000..a5756ee --- /dev/null +++ b/templates/nav.html @@ -0,0 +1,8 @@ +
diff --git a/templates/partials/roast_list.html b/templates/partials/roast_list.html new file mode 100644 index 0000000..b1b9487 --- /dev/null +++ b/templates/partials/roast_list.html @@ -0,0 +1,255 @@ +
+ {% if roasts.items.is_empty() %} +
+

No roasts recorded yet. Use the form above to add your first roast.

+
+ {% else %} +
+
+

+ Showing {{ roasts.start_index() }}–{{ roasts.end_index() }} of {{ roasts.total }} + roasts +

+
+
+ + {% if roasts.is_showing_all() %} + Showing all results + {% else %} + Page {{ roasts.page }} of {{ roasts.total_pages() }} + {% endif %} + +
+ +
+
+
+ + + + {% set created_sorted = navigator.is_sorted_by("created-at") %} + + {% set name_sorted = navigator.is_sorted_by("name") %} + + {% set roaster_sorted = navigator.is_sorted_by("roaster") %} + + {% set origin_sorted = navigator.is_sorted_by("origin") %} + + {% set producer_sorted = navigator.is_sorted_by("producer") %} + + + + + + + {% for roast in roasts.items %} + + + + + + + + + + {% endfor %} + +
+ + + + + + + + + + NotesActions
+ {{ roast.created_at }} + + + {{ roast.roaster_label }}{{ roast.origin }}{{ roast.producer }} + {% if roast.tasting_notes.is_empty() %} + No tasting notes yet. + {% else %} +
    + {% for note in roast.tasting_notes %} +
  • + {{ note }} +
  • + {% endfor %} +
+ {% endif %} +
+ +
+
+
+ {% endif %} +
diff --git a/templates/partials/roaster_list.html b/templates/partials/roaster_list.html new file mode 100644 index 0000000..ea2c5d7 --- /dev/null +++ b/templates/partials/roaster_list.html @@ -0,0 +1,240 @@ +
+ {% if roasters.items.is_empty() %} +
+

+ No roasters recorded yet. Use the form above to add your first roaster. +

+
+ {% else %} +
+
+

+ Showing {{ roasters.start_index() }}–{{ roasters.end_index() }} of {{ roasters.total + }} roasters +

+
+
+ + {% if roasters.is_showing_all() %} + Showing all results + {% else %} + Page {{ roasters.page }} of {{ roasters.total_pages() }} + {% endif %} + +
+ +
+
+
+ + + + {% set created_sorted = navigator.is_sorted_by("created-at") %} + + {% set name_sorted = navigator.is_sorted_by("name") %} + + {% set country_sorted = navigator.is_sorted_by("country") %} + + {% set city_sorted = navigator.is_sorted_by("city") %} + + + + + + + + {% for roaster in roasters.items %} + + + + + + + + + + {% endfor %} + +
+ + + + + + + + HomepageNotesActions
+ {{ roaster.created_at }} + + {{ roaster.name }} + {{ roaster.country }}{{ roaster.city }} + {% if roaster.has_homepage %} + + + + {% else %} + + {% endif %} + {{ roaster.notes }} + +
+
+
+ {% endif %} +
diff --git a/templates/roast_detail.html b/templates/roast_detail.html new file mode 100644 index 0000000..8102fa2 --- /dev/null +++ b/templates/roast_detail.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} {% block title %}Brewlog · Roast · {{ roast.name }}{% endblock %} {% +block content %} +
+

{{ roast.name }}

+

Detailed view for this roast.

+
+ +
+
+
+
Roaster
+
{{ roast.roaster_label }}
+
+
+
Origin
+
{{ roast.origin }}
+
+
+
Region
+
{{ roast.region }}
+
+
+
Producer
+
{{ roast.producer }}
+
+
+
Process
+
{{ roast.process }}
+
+
+
Created
+
{{ roast.created_at }}
+
+
+ {% if roast.tasting_notes.is_empty() %} +

No tasting notes yet.

+ {% else %} +
    + {% for note in roast.tasting_notes %} +
  • + {{ note }} +
  • + {% endfor %} +
+ {% endif %} +
+{% endblock %} diff --git a/templates/roaster_detail.html b/templates/roaster_detail.html new file mode 100644 index 0000000..6c47908 --- /dev/null +++ b/templates/roaster_detail.html @@ -0,0 +1,116 @@ +{% extends "base.html" %} {% block title %}Brewlog · Roaster · {{ roaster.name }}{% endblock %} {% +block content %} +
+

{{ roaster.name }}

+

Detailed view for {{ roaster.name }}.

+
+
+
+
+
Country
+
{{ roaster.country }}
+
+
+
City
+
{{ roaster.city }}
+
+
+
Homepage
+
+ {% if roaster.has_homepage %} + + + Visit + + {% else %} + + {% endif %} +
+
+
+
Created
+
{{ roaster.created_at }}
+
+
+

{{ roaster.notes }}

+
+ +
+
+

Roasts

+
+ {% if roasts.is_empty() %} +

This roaster has no roasts yet. Use the CLI to add one.

+ {% else %} +
+ + + + + + + + + + + + + + {% for roast in roasts %} + + + + + + + + + + {% endfor %} + +
AddedRoastOriginRegionProducerProcessTasting Notes
+ {{ roast.created_at }} + + {{ roast.name }} + {{ roast.origin }}{{ roast.region }}{{ roast.producer }}{{ roast.process }} + {% if roast.tasting_notes.is_empty() %} + No tasting notes yet. + {% else %} +
    + {% for note in roast.tasting_notes %} +
  • + {{ note }} +
  • + {% endfor %} +
+ {% endif %} +
+
+ {% endif %} +
+{% endblock %} diff --git a/templates/roasters.html b/templates/roasters.html new file mode 100644 index 0000000..b5d702e --- /dev/null +++ b/templates/roasters.html @@ -0,0 +1,101 @@ +{% extends "base.html" %} {% block title %}Brewlog · Roasters{% endblock %} {% block content %} +
+
+
+

Roasters

+

Browse the coffee roasters known to Brewlog.

+
+ +
+ + +
+ +{% include "partials/roaster_list.html" %} {% endblock %} diff --git a/templates/roasts.html b/templates/roasts.html new file mode 100644 index 0000000..7746a42 --- /dev/null +++ b/templates/roasts.html @@ -0,0 +1,125 @@ +{% extends "base.html" %} {% block title %}Brewlog · Roasts{% endblock %} {% block content %} +
+
+
+

Roasts

+

Explore the latest roasts logged in Brewlog.

+
+ {% if !roaster_options.is_empty() %} + + {% endif %} +
+ + {% if roaster_options.is_empty() %} +
+

Add a roaster first

+

+ Roasts need a roaster. + Create a roaster + to enable this form. +

+
+ {% else %} + + {% endif %} +
+ +{% include "partials/roast_list.html" %} {% endblock %} diff --git a/templates/styles.css b/templates/styles.css new file mode 100644 index 0000000..cd31a82 --- /dev/null +++ b/templates/styles.css @@ -0,0 +1,26 @@ +:root { + color-scheme: light; +} + +body { + font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +a { + text-decoration: none; +} + +.input-field { + border-radius: 0.5rem; + border: 1px solid rgba(120, 53, 15, 0.3); + background-color: rgba(254, 243, 199, 0.85); + padding: 0.6rem 0.75rem; + color: #4a2f0b; + transition: border-color 150ms ease, box-shadow 150ms ease; +} + +.input-field:focus { + outline: none; + border-color: rgba(180, 83, 9, 0.6); + box-shadow: 0 0 0 2px rgba(180, 83, 9, 0.2); +} diff --git a/templates/timeline.html b/templates/timeline.html new file mode 100644 index 0000000..3fa1fa0 --- /dev/null +++ b/templates/timeline.html @@ -0,0 +1,120 @@ +{% extends "base.html" %} {% block title %}Brewlog · Timeline{% endblock %} {% block content %} +
+

Timeline

+

+ Follow the history of roasters and roasts in Brewlog. +

+
+ +
+
+ {% if months.is_empty() %} +

+ No events yet. Create roasters or roasts to populate the timeline. +

+ {% else %} {% for month in months %} +
+

{{ month.heading }}

+
    + {% for event in month.events %} +
  1. + +
    +
    + {{ event.kind_label }} + +
    +

    + {{ event.title }} + {% if let Some(url) = event.external_link %} + + + Open external link + + {% endif %} +

    + {% if event.details.len() > 0 %} +
    + {% for detail in event.details %} +
    +
    {{ detail.label }}
    +
    {{ detail.value }}
    +
    + {% endfor %} +
    + {% endif %} {% if let Some(notes) = event.tasting_notes %} {% if notes.is_empty() %} +

    No tasting notes yet.

    + {% else %} +
      + {% for note in notes %} +
    • + {{ note }} +
    • + {% endfor %} +
    + {% endif %} {% endif %} +
    +
  2. + {% endfor %} +
+
+ {% endfor %} {% endif %} +
+ +
+{% endblock %}