carlos / todomvc-bakeoff Public
feat(rust): TodoMVC server on axum, maud and rusqlite
Server-rendered list with no-JS form controls, a small ES module that swaps in list fragments, per-visitor cookie lists, origin check, tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 files changed,
+1929
−0
apps/rust/Cargo.lock+759 −0apps/rust/Cargo.toml+27 −0apps/rust/DEPS+6 −0apps/rust/Makefile+42 −0apps/rust/src/app.rs+308 −0apps/rust/src/db.rs+137 −0apps/rust/src/lib.rs+6 −0apps/rust/src/main.rs+134 −0apps/rust/src/views.rs+183 −0apps/rust/static/app.js+97 −0apps/rust/tests/http.rs+230 −0
diff --git a/apps/rust/Cargo.lock b/apps/rust/Cargo.lock| new file mode 100644 |
| index 0000000..edb224f |
| --- /dev/null |
| +++ b/apps/rust/Cargo.lock |
| @@ -0,0 +1,759 @@ |
| +# This file is automatically @generated by Cargo. |
| +# It is not intended for manual editing. |
| +version = 4 |
| + |
| +[[package]] |
| +name = "atomic-waker" |
| +version = "1.1.2" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" |
| + |
| +[[package]] |
| +name = "axum" |
| +version = "0.8.9" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" |
| +dependencies = [ |
| + "axum-core", |
| + "bytes", |
| + "form_urlencoded", |
| + "futures-util", |
| + "http", |
| + "http-body", |
| + "http-body-util", |
| + "hyper", |
| + "hyper-util", |
| + "itoa", |
| + "matchit", |
| + "memchr", |
| + "mime", |
| + "percent-encoding", |
| + "pin-project-lite", |
| + "serde_core", |
| + "serde_path_to_error", |
| + "serde_urlencoded", |
| + "sync_wrapper", |
| + "tokio", |
| + "tower", |
| + "tower-layer", |
| + "tower-service", |
| +] |
| + |
| +[[package]] |
| +name = "axum-core" |
| +version = "0.5.6" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" |
| +dependencies = [ |
| + "bytes", |
| + "futures-core", |
| + "http", |
| + "http-body", |
| + "http-body-util", |
| + "mime", |
| + "pin-project-lite", |
| + "sync_wrapper", |
| + "tower-layer", |
| + "tower-service", |
| +] |
| + |
| +[[package]] |
| +name = "bitflags" |
| +version = "2.13.2" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" |
| + |
| +[[package]] |
| +name = "bumpalo" |
| +version = "3.20.3" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" |
| + |
| +[[package]] |
| +name = "bytes" |
| +version = "1.12.1" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" |
| + |
| +[[package]] |
| +name = "cc" |
| +version = "1.10.0" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" |
| +dependencies = [ |
| + "find-msvc-tools", |
| + "shlex", |
| +] |
| + |
| +[[package]] |
| +name = "cfg-if" |
| +version = "1.0.5" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" |
| + |
| +[[package]] |
| +name = "errno" |
| +version = "0.3.14" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" |
| +dependencies = [ |
| + "libc", |
| + "windows-sys", |
| +] |
| + |
| +[[package]] |
| +name = "fallible-iterator" |
| +version = "0.3.0" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" |
| + |
| +[[package]] |
| +name = "fallible-streaming-iterator" |
| +version = "0.1.9" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" |
| + |
| +[[package]] |
| +name = "find-msvc-tools" |
| +version = "0.1.15" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" |
| + |
| +[[package]] |
| +name = "foldhash" |
| +version = "0.2.0" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" |
| + |
| +[[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-core" |
| +version = "0.3.34" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" |
| + |
| +[[package]] |
| +name = "futures-task" |
| +version = "0.3.34" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" |
| + |
| +[[package]] |
| +name = "futures-util" |
| +version = "0.3.34" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" |
| +dependencies = [ |
| + "futures-core", |
| + "futures-task", |
| + "pin-project-lite", |
| + "slab", |
| +] |
| + |
| +[[package]] |
| +name = "getrandom" |
| +version = "0.4.3" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" |
| +dependencies = [ |
| + "cfg-if", |
| + "libc", |
| + "r-efi", |
| +] |
| + |
| +[[package]] |
| +name = "hashbrown" |
| +version = "0.16.1" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" |
| +dependencies = [ |
| + "foldhash", |
| +] |
| + |
| +[[package]] |
| +name = "hashbrown" |
| +version = "0.17.1" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" |
| +dependencies = [ |
| + "foldhash", |
| +] |
| + |
| +[[package]] |
| +name = "hashlink" |
| +version = "0.12.2" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" |
| +dependencies = [ |
| + "hashbrown 0.17.1", |
| +] |
| + |
| +[[package]] |
| +name = "http" |
| +version = "1.5.0" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" |
| +dependencies = [ |
| + "bytes", |
| + "itoa", |
| +] |
| + |
| +[[package]] |
| +name = "http-body" |
| +version = "1.1.0" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" |
| +dependencies = [ |
| + "bytes", |
| + "http", |
| +] |
| + |
| +[[package]] |
| +name = "http-body-util" |
| +version = "0.1.5" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" |
| +dependencies = [ |
| + "bytes", |
| + "futures-core", |
| + "http", |
| + "http-body", |
| + "pin-project-lite", |
| +] |
| + |
| +[[package]] |
| +name = "httparse" |
| +version = "1.10.1" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" |
| + |
| +[[package]] |
| +name = "httpdate" |
| +version = "1.0.3" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" |
| + |
| +[[package]] |
| +name = "hyper" |
| +version = "1.12.0" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" |
| +dependencies = [ |
| + "atomic-waker", |
| + "bytes", |
| + "futures-core", |
| + "http", |
| + "http-body", |
| + "httparse", |
| + "httpdate", |
| + "itoa", |
| + "pin-project-lite", |
| + "smallvec", |
| + "tokio", |
| +] |
| + |
| +[[package]] |
| +name = "hyper-util" |
| +version = "0.1.21" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" |
| +dependencies = [ |
| + "bytes", |
| + "http", |
| + "http-body", |
| + "hyper", |
| + "pin-project-lite", |
| + "tokio", |
| + "tower-service", |
| +] |
| + |
| +[[package]] |
| +name = "itoa" |
| +version = "1.0.18" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" |
| + |
| +[[package]] |
| +name = "js-sys" |
| +version = "0.3.106" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" |
| +dependencies = [ |
| + "cfg-if", |
| + "wasm-bindgen", |
| +] |
| + |
| +[[package]] |
| +name = "libc" |
| +version = "0.2.191" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" |
| + |
| +[[package]] |
| +name = "libsqlite3-sys" |
| +version = "0.38.2" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" |
| +dependencies = [ |
| + "cc", |
| + "pkg-config", |
| + "vcpkg", |
| +] |
| + |
| +[[package]] |
| +name = "matchit" |
| +version = "0.8.4" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" |
| + |
| +[[package]] |
| +name = "maud" |
| +version = "0.27.0" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e" |
| +dependencies = [ |
| + "itoa", |
| + "maud_macros", |
| +] |
| + |
| +[[package]] |
| +name = "maud_macros" |
| +version = "0.27.0" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca" |
| +dependencies = [ |
| + "proc-macro2", |
| + "proc-macro2-diagnostics", |
| + "quote", |
| + "syn 2.0.119", |
| +] |
| + |
| +[[package]] |
| +name = "memchr" |
| +version = "2.8.3" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" |
| + |
| +[[package]] |
| +name = "mime" |
| +version = "0.3.17" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" |
| + |
| +[[package]] |
| +name = "mio" |
| +version = "1.2.4" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" |
| +dependencies = [ |
| + "libc", |
| + "wasi", |
| + "windows-sys", |
| +] |
| + |
| +[[package]] |
| +name = "once_cell" |
| +version = "1.21.4" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" |
| + |
| +[[package]] |
| +name = "percent-encoding" |
| +version = "2.3.2" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" |
| + |
| +[[package]] |
| +name = "pin-project-lite" |
| +version = "0.2.17" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" |
| + |
| +[[package]] |
| +name = "pkg-config" |
| +version = "0.3.34" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" |
| + |
| +[[package]] |
| +name = "proc-macro2" |
| +version = "1.0.107" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" |
| +dependencies = [ |
| + "unicode-ident", |
| +] |
| + |
| +[[package]] |
| +name = "proc-macro2-diagnostics" |
| +version = "0.10.1" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" |
| +dependencies = [ |
| + "proc-macro2", |
| + "quote", |
| + "syn 2.0.119", |
| + "version_check", |
| +] |
| + |
| +[[package]] |
| +name = "quote" |
| +version = "1.0.47" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" |
| +dependencies = [ |
| + "proc-macro2", |
| +] |
| + |
| +[[package]] |
| +name = "r-efi" |
| +version = "6.0.0" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" |
| + |
| +[[package]] |
| +name = "rsqlite-vfs" |
| +version = "0.1.1" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" |
| +dependencies = [ |
| + "hashbrown 0.16.1", |
| + "thiserror", |
| +] |
| + |
| +[[package]] |
| +name = "rusqlite" |
| +version = "0.40.2" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" |
| +dependencies = [ |
| + "bitflags", |
| + "fallible-iterator", |
| + "fallible-streaming-iterator", |
| + "hashlink", |
| + "libsqlite3-sys", |
| + "smallvec", |
| + "sqlite-wasm-rs", |
| +] |
| + |
| +[[package]] |
| +name = "rustversion" |
| +version = "1.0.23" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" |
| + |
| +[[package]] |
| +name = "ryu" |
| +version = "1.0.23" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" |
| + |
| +[[package]] |
| +name = "serde" |
| +version = "1.0.229" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" |
| +dependencies = [ |
| + "serde_core", |
| + "serde_derive", |
| +] |
| + |
| +[[package]] |
| +name = "serde_core" |
| +version = "1.0.229" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" |
| +dependencies = [ |
| + "serde_derive", |
| +] |
| + |
| +[[package]] |
| +name = "serde_derive" |
| +version = "1.0.229" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" |
| +dependencies = [ |
| + "proc-macro2", |
| + "quote", |
| + "syn 3.0.9", |
| +] |
| + |
| +[[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 = "shlex" |
| +version = "2.0.1" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" |
| + |
| +[[package]] |
| +name = "signal-hook-registry" |
| +version = "1.4.8" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" |
| +dependencies = [ |
| + "errno", |
| + "libc", |
| +] |
| + |
| +[[package]] |
| +name = "slab" |
| +version = "0.4.12" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" |
| + |
| +[[package]] |
| +name = "smallvec" |
| +version = "1.16.4" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" |
| + |
| +[[package]] |
| +name = "socket2" |
| +version = "0.6.5" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" |
| +dependencies = [ |
| + "libc", |
| + "windows-sys", |
| +] |
| + |
| +[[package]] |
| +name = "sqlite-wasm-rs" |
| +version = "0.5.5" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" |
| +dependencies = [ |
| + "cc", |
| + "js-sys", |
| + "rsqlite-vfs", |
| + "wasm-bindgen", |
| +] |
| + |
| +[[package]] |
| +name = "syn" |
| +version = "2.0.119" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" |
| +dependencies = [ |
| + "proc-macro2", |
| + "quote", |
| + "unicode-ident", |
| +] |
| + |
| +[[package]] |
| +name = "syn" |
| +version = "3.0.9" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" |
| +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" |
| + |
| +[[package]] |
| +name = "thiserror" |
| +version = "2.0.21" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" |
| +dependencies = [ |
| + "thiserror-impl", |
| +] |
| + |
| +[[package]] |
| +name = "thiserror-impl" |
| +version = "2.0.21" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" |
| +dependencies = [ |
| + "proc-macro2", |
| + "quote", |
| + "syn 3.0.9", |
| +] |
| + |
| +[[package]] |
| +name = "todo-rust" |
| +version = "0.1.0" |
| +dependencies = [ |
| + "axum", |
| + "getrandom", |
| + "http-body-util", |
| + "maud", |
| + "rusqlite", |
| + "serde", |
| + "tokio", |
| + "tower", |
| +] |
| + |
| +[[package]] |
| +name = "tokio" |
| +version = "1.54.1" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" |
| +dependencies = [ |
| + "libc", |
| + "mio", |
| + "pin-project-lite", |
| + "signal-hook-registry", |
| + "socket2", |
| + "tokio-macros", |
| + "windows-sys", |
| +] |
| + |
| +[[package]] |
| +name = "tokio-macros" |
| +version = "2.7.2" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" |
| +dependencies = [ |
| + "proc-macro2", |
| + "quote", |
| + "syn 3.0.9", |
| +] |
| + |
| +[[package]] |
| +name = "tower" |
| +version = "0.5.3" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" |
| +dependencies = [ |
| + "futures-core", |
| + "futures-util", |
| + "pin-project-lite", |
| + "sync_wrapper", |
| + "tokio", |
| + "tower-layer", |
| + "tower-service", |
| +] |
| + |
| +[[package]] |
| +name = "tower-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 = "unicode-ident" |
| +version = "1.0.27" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" |
| + |
| +[[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 = "wasi" |
| +version = "0.11.1+wasi-snapshot-preview1" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" |
| + |
| +[[package]] |
| +name = "wasm-bindgen" |
| +version = "0.2.129" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" |
| +dependencies = [ |
| + "cfg-if", |
| + "once_cell", |
| + "rustversion", |
| + "wasm-bindgen-macro", |
| + "wasm-bindgen-shared", |
| +] |
| + |
| +[[package]] |
| +name = "wasm-bindgen-macro" |
| +version = "0.2.129" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" |
| +dependencies = [ |
| + "quote", |
| + "wasm-bindgen-macro-support", |
| +] |
| + |
| +[[package]] |
| +name = "wasm-bindgen-macro-support" |
| +version = "0.2.129" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" |
| +dependencies = [ |
| + "bumpalo", |
| + "proc-macro2", |
| + "quote", |
| + "syn 3.0.9", |
| + "wasm-bindgen-shared", |
| +] |
| + |
| +[[package]] |
| +name = "wasm-bindgen-shared" |
| +version = "0.2.129" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" |
| +dependencies = [ |
| + "unicode-ident", |
| +] |
| + |
| +[[package]] |
| +name = "windows-link" |
| +version = "0.2.1" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" |
| + |
| +[[package]] |
| +name = "windows-sys" |
| +version = "0.61.2" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" |
| +dependencies = [ |
| + "windows-link", |
| +] |
diff --git a/apps/rust/Cargo.toml b/apps/rust/Cargo.toml| new file mode 100644 |
| index 0000000..e553449 |
| --- /dev/null |
| +++ b/apps/rust/Cargo.toml |
| @@ -0,0 +1,27 @@ |
| +[package] |
| +name = "todo-rust" |
| +version = "0.1.0" |
| +edition = "2024" |
| +publish = false |
| + |
| +[[bin]] |
| +name = "todo-rust" |
| +path = "src/main.rs" |
| + |
| +[dependencies] |
| +axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio", "form", "query"] } |
| +getrandom = "0.4.3" |
| +maud = "0.27.0" |
| +rusqlite = { version = "0.40.2", features = ["bundled"] } |
| +serde = { version = "1.0.229", features = ["derive"] } |
| +tokio = { version = "1.54.1", features = ["rt-multi-thread", "net", "signal", "macros"] } |
| + |
| +[dev-dependencies] |
| +http-body-util = "0.1.5" |
| +tower = { version = "0.5.3", features = ["util"] } |
| + |
| +[profile.release] |
| +opt-level = 3 |
| +lto = "thin" |
| +codegen-units = 1 |
| +strip = true |
diff --git a/apps/rust/DEPS b/apps/rust/DEPS| new file mode 100644 |
| index 0000000..263944e |
| --- /dev/null |
| +++ b/apps/rust/DEPS |
| @@ -0,0 +1,6 @@ |
| +axum |
| +getrandom |
| +maud |
| +rusqlite |
| +serde |
| +tokio |
diff --git a/apps/rust/Makefile b/apps/rust/Makefile| new file mode 100644 |
| index 0000000..1425ee6 |
| --- /dev/null |
| +++ b/apps/rust/Makefile |
| @@ -0,0 +1,42 @@ |
| +# todo-rust. Cargo's build output lives in .cache/target (git-ignored); |
| +# downloaded crate sources stay in ~/.cargo/registry. |
| +SHELL := /bin/bash |
| +TARGET_DIR := .cache/target |
| +ARM64 := aarch64-unknown-linux-musl |
| +VERSION := $(shell git rev-parse --short HEAD 2>/dev/null || echo dev) |
| +CARGO := source ../../scripts/env.sh && APP_VERSION=$(VERSION) CARGO_TARGET_DIR=$(TARGET_DIR) cargo |
| +APP := todo-rust |
| +HOST := todo-rust.bfj.oncarlos.com |
| +MESSAGE ?= Deploy $(VERSION) |
| + |
| +.PHONY: build build-arm64 test clean clean-deps bump unbump deploy |
| + |
| +build: |
| + $(CARGO) build --release --locked |
| + mkdir -p dist && cp $(TARGET_DIR)/release/$(APP) dist/app |
| + |
| +build-arm64: |
| + $(CARGO) zigbuild --release --locked --target $(ARM64) |
| + mkdir -p dist && cp $(TARGET_DIR)/$(ARM64)/release/$(APP) dist/app-linux-arm64 |
| + |
| +test: |
| + $(CARGO) test --locked |
| + |
| +# Only this crate's own artifacts; compiled dependencies stay. |
| +clean: |
| + rm -rf dist |
| + $(CARGO) clean --release -p $(APP) |
| + $(CARGO) clean --release --target $(ARM64) -p $(APP) |
| + |
| +# Every compiled artifact, dependencies included. Sources stay downloaded. |
| +clean-deps: clean |
| + rm -rf $(TARGET_DIR) |
| + |
| +bump: |
| + sed -i 's/^const BUMP: &str = "unbumped";/const BUMP: \&str = "bumped";/' src/app.rs |
| + |
| +unbump: |
| + sed -i 's/^const BUMP: &str = "bumped";/const BUMP: \&str = "unbumped";/' src/app.rs |
| + |
| +deploy: |
| + carlos deploy --account bfj --app $(APP) --target linux-arm64 --message "$(MESSAGE)" dist/app-linux-arm64 |
diff --git a/apps/rust/src/app.rs b/apps/rust/src/app.rs| new file mode 100644 |
| index 0000000..ef3b4f8 |
| --- /dev/null |
| +++ b/apps/rust/src/app.rs |
| @@ -0,0 +1,308 @@ |
| +//! Routes and handlers. `GET /` and the filter pages live here too. |
| + |
| +use axum::{ |
| + Router, |
| + extract::{Form, Path, Query, Request, State}, |
| + http::{HeaderMap, HeaderValue, Method, StatusCode, header}, |
| + middleware::{self, Next}, |
| + response::{Html, IntoResponse, Redirect, Response}, |
| + routing::{get, post}, |
| +}; |
| +use rusqlite::Connection; |
| +use serde::Deserialize; |
| + |
| +use crate::db::{self, Db}; |
| +use crate::views::{self, Filter, Page}; |
| + |
| +/// `make bump` rewrites this string so the build has to recompile this file. |
| +#[allow(dead_code)] |
| +const BUMP: &str = "unbumped"; |
| + |
| +const LANGUAGE: &str = "Rust"; |
| +const COOKIE: &str = "todo_list"; |
| +/// Requests carrying this header get the list fragment back instead of a 303. |
| +const FRAGMENT_HEADER: &str = "x-todo-fragment"; |
| + |
| +#[derive(Clone)] |
| +pub struct AppState { |
| + pub db: Db, |
| + /// The app's own origin, e.g. `https://todo-rust.bfj.oncarlos.com`. |
| + /// POSTs whose `Origin` names anything else are refused. |
| + pub origin: String, |
| + pub version: &'static str, |
| +} |
| + |
| +impl AppState { |
| + fn secure(&self) -> bool { |
| + self.origin.starts_with("https://") |
| + } |
| +} |
| + |
| +pub fn router(state: AppState) -> Router { |
| + Router::new() |
| + .route("/", get(page_all)) |
| + .route("/active", get(page_active)) |
| + .route("/completed", get(page_completed)) |
| + .route("/todos", post(add)) |
| + .route("/todos/toggle-all", post(toggle_all)) |
| + .route("/todos/clear-completed", post(clear_completed)) |
| + .route("/todos/{id}/toggle", post(toggle)) |
| + .route("/todos/{id}/edit", post(edit)) |
| + .route("/todos/{id}/destroy", post(destroy)) |
| + .route("/static/base.css", get(|| css(include_str!("../../../shared/base.css")))) |
| + .route("/static/index.css", get(|| css(include_str!("../../../shared/index.css")))) |
| + .route("/static/app.js", get(app_js)) |
| + .route("/healthz", get(|| async { "ok" })) |
| + .route("/api/version", get(|State(s): State<AppState>| async move { s.version })) |
| + .layer(middleware::from_fn_with_state(state.clone(), same_origin)) |
| + .with_state(state) |
| +} |
| + |
| +/// Refuses a POST whose `Origin` is present and isn't ours. No `Origin` |
| +/// header is allowed: the contract has no CSRF token, and SameSite=Lax |
| +/// already keeps the cookie off cross-site POSTs. |
| +async fn same_origin(State(s): State<AppState>, req: Request, next: Next) -> Response { |
| + if req.method() == Method::POST { |
| + if let Some(origin) = req.headers().get(header::ORIGIN) { |
| + if origin.as_bytes() != s.origin.as_bytes() { |
| + return StatusCode::FORBIDDEN.into_response(); |
| + } |
| + } |
| + } |
| + next.run(req).await |
| +} |
| + |
| +// ---- the visitor's list ---------------------------------------------- |
| + |
| +/// The list a request belongs to, plus the cookie to set if it needed a |
| +/// new one. |
| +struct Visitor { |
| + list: i64, |
| + new_token: Option<String>, |
| +} |
| + |
| +fn cookie_token(headers: &HeaderMap) -> Option<String> { |
| + headers |
| + .get_all(header::COOKIE) |
| + .iter() |
| + .filter_map(|v| v.to_str().ok()) |
| + .flat_map(|v| v.split(';')) |
| + .filter_map(|pair| pair.trim().split_once('=')) |
| + .find(|(name, _)| *name == COOKIE) |
| + .map(|(_, value)| value.to_owned()) |
| + .filter(|v| v.len() == 32 && v.bytes().all(|b| b.is_ascii_hexdigit())) |
| +} |
| + |
| +fn new_token() -> String { |
| + let mut bytes = [0u8; 16]; |
| + getrandom::fill(&mut bytes).expect("no system randomness"); |
| + bytes.iter().map(|b| format!("{b:02x}")).collect() |
| +} |
| + |
| +/// Finds the list the cookie names. A missing, garbled or unknown cookie |
| +/// gets a fresh empty list. |
| +fn resolve(conn: &Connection, token: Option<String>) -> rusqlite::Result<Visitor> { |
| + if let Some(token) = token { |
| + if let Some(list) = db::find_list(conn, &token)? { |
| + return Ok(Visitor { list, new_token: None }); |
| + } |
| + } |
| + let token = new_token(); |
| + let list = db::create_list(conn, &token)?; |
| + Ok(Visitor { list, new_token: Some(token) }) |
| +} |
| + |
| +fn set_cookie(state: &AppState, visitor: &Visitor, response: &mut Response) { |
| + if let Some(token) = &visitor.new_token { |
| + let secure = if state.secure() { "; Secure" } else { "" }; |
| + let value = format!("{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000{secure}"); |
| + response |
| + .headers_mut() |
| + .append(header::SET_COOKIE, HeaderValue::from_str(&value).expect("ascii cookie")); |
| + } |
| +} |
| + |
| +// ---- pages ----------------------------------------------------------- |
| + |
| +#[derive(Deserialize)] |
| +struct PageQuery { |
| + edit: Option<String>, |
| +} |
| + |
| +async fn page_all(s: State<AppState>, h: HeaderMap, q: Query<PageQuery>) -> Result<Response, AppError> { |
| + render_page(s, h, q, Filter::All).await |
| +} |
| + |
| +async fn page_active(s: State<AppState>, h: HeaderMap, q: Query<PageQuery>) -> Result<Response, AppError> { |
| + render_page(s, h, q, Filter::Active).await |
| +} |
| + |
| +async fn page_completed(s: State<AppState>, h: HeaderMap, q: Query<PageQuery>) -> Result<Response, AppError> { |
| + render_page(s, h, q, Filter::Completed).await |
| +} |
| + |
| +async fn render_page( |
| + State(state): State<AppState>, |
| + headers: HeaderMap, |
| + Query(q): Query<PageQuery>, |
| + filter: Filter, |
| +) -> Result<Response, AppError> { |
| + let token = cookie_token(&headers); |
| + let (visitor, todos) = state |
| + .db |
| + .run(move |c| { |
| + let v = resolve(c, token)?; |
| + let todos = db::todos(c, v.list)?; |
| + Ok((v, todos)) |
| + }) |
| + .await?; |
| + let editing = q.edit.and_then(|e| e.parse().ok()); |
| + let markup = views::page(&Page { todos: &todos, filter, editing, language: LANGUAGE }); |
| + let mut response = (no_store(), Html(markup.into_string())).into_response(); |
| + set_cookie(&state, &visitor, &mut response); |
| + Ok(response) |
| +} |
| + |
| +// ---- actions --------------------------------------------------------- |
| + |
| +#[derive(Deserialize)] |
| +struct ActionForm { |
| + title: Option<String>, |
| + filter: Option<String>, |
| +} |
| + |
| +enum Outcome { |
| + Done, |
| + NotFound, |
| +} |
| + |
| +/// Runs one action against the visitor's list, then answers the way the |
| +/// contract says: 404 for an id not in the list, otherwise a 303 back to |
| +/// the filter, or the list fragment when our JavaScript asked for it. |
| +async fn act<F>(state: AppState, headers: HeaderMap, filter: Option<String>, action: F) -> Result<Response, AppError> |
| +where |
| + F: FnOnce(&Connection, i64) -> rusqlite::Result<Outcome> + Send + 'static, |
| +{ |
| + let filter = Filter::from_field(filter.as_deref()); |
| + let token = cookie_token(&headers); |
| + let want_fragment = headers.contains_key(FRAGMENT_HEADER); |
| + let (visitor, outcome, todos) = state |
| + .db |
| + .run(move |c| { |
| + let v = resolve(c, token)?; |
| + let tx = c.transaction()?; |
| + let outcome = action(&tx, v.list)?; |
| + tx.commit()?; |
| + let todos = match (&outcome, want_fragment) { |
| + (Outcome::Done, true) => Some(db::todos(c, v.list)?), |
| + _ => None, |
| + }; |
| + Ok((v, outcome, todos)) |
| + }) |
| + .await?; |
| + let mut response = match (outcome, todos) { |
| + (Outcome::NotFound, _) => StatusCode::NOT_FOUND.into_response(), |
| + (Outcome::Done, Some(todos)) => { |
| + let markup = views::list(&Page { todos: &todos, filter, editing: None, language: LANGUAGE }); |
| + (no_store(), Html(markup.into_string())).into_response() |
| + } |
| + (Outcome::Done, None) => Redirect::to(filter.path()).into_response(), |
| + }; |
| + set_cookie(&state, &visitor, &mut response); |
| + Ok(response) |
| +} |
| + |
| +fn found(hit: bool) -> Outcome { |
| + if hit { Outcome::Done } else { Outcome::NotFound } |
| +} |
| + |
| +/// Ids are positive integers; anything else can't be in the list. |
| +fn parse_id(raw: &str) -> Option<i64> { |
| + raw.parse().ok().filter(|id| *id > 0) |
| +} |
| + |
| +async fn add(State(s): State<AppState>, h: HeaderMap, Form(f): Form<ActionForm>) -> Result<Response, AppError> { |
| + let title = f.title.unwrap_or_default().trim().to_owned(); |
| + act(s, h, f.filter, move |c, list| { |
| + if !title.is_empty() { |
| + db::add(c, list, &title)?; |
| + } |
| + Ok(Outcome::Done) |
| + }) |
| + .await |
| +} |
| + |
| +async fn toggle( |
| + State(s): State<AppState>, |
| + h: HeaderMap, |
| + Path(id): Path<String>, |
| + Form(f): Form<ActionForm>, |
| +) -> Result<Response, AppError> { |
| + let Some(id) = parse_id(&id) else { return Ok(StatusCode::NOT_FOUND.into_response()) }; |
| + act(s, h, f.filter, move |c, list| Ok(found(db::toggle(c, list, id)?))).await |
| +} |
| + |
| +async fn edit( |
| + State(s): State<AppState>, |
| + h: HeaderMap, |
| + Path(id): Path<String>, |
| + Form(f): Form<ActionForm>, |
| +) -> Result<Response, AppError> { |
| + let Some(id) = parse_id(&id) else { return Ok(StatusCode::NOT_FOUND.into_response()) }; |
| + let title = f.title.unwrap_or_default().trim().to_owned(); |
| + act(s, h, f.filter, move |c, list| { |
| + let hit = if title.is_empty() { db::destroy(c, list, id)? } else { db::rename(c, list, id, &title)? }; |
| + Ok(found(hit)) |
| + }) |
| + .await |
| +} |
| + |
| +async fn destroy( |
| + State(s): State<AppState>, |
| + h: HeaderMap, |
| + Path(id): Path<String>, |
| + Form(f): Form<ActionForm>, |
| +) -> Result<Response, AppError> { |
| + let Some(id) = parse_id(&id) else { return Ok(StatusCode::NOT_FOUND.into_response()) }; |
| + act(s, h, f.filter, move |c, list| Ok(found(db::destroy(c, list, id)?))).await |
| +} |
| + |
| +async fn toggle_all(State(s): State<AppState>, h: HeaderMap, Form(f): Form<ActionForm>) -> Result<Response, AppError> { |
| + act(s, h, f.filter, |c, list| db::toggle_all(c, list).map(|_| Outcome::Done)).await |
| +} |
| + |
| +async fn clear_completed(State(s): State<AppState>, h: HeaderMap, Form(f): Form<ActionForm>) -> Result<Response, AppError> { |
| + act(s, h, f.filter, |c, list| db::clear_completed(c, list).map(|_| Outcome::Done)).await |
| +} |
| + |
| +// ---- static files and helpers ---------------------------------------- |
| + |
| +async fn css(body: &'static str) -> impl IntoResponse { |
| + ([(header::CONTENT_TYPE, "text/css; charset=utf-8"), (header::CACHE_CONTROL, "public, max-age=3600")], body) |
| +} |
| + |
| +async fn app_js() -> impl IntoResponse { |
| + ( |
| + [(header::CONTENT_TYPE, "text/javascript; charset=utf-8"), (header::CACHE_CONTROL, "public, max-age=3600")], |
| + include_str!("../static/app.js"), |
| + ) |
| +} |
| + |
| +fn no_store() -> [(header::HeaderName, &'static str); 1] { |
| + [(header::CACHE_CONTROL, "no-store")] |
| +} |
| + |
| +pub struct AppError(rusqlite::Error); |
| + |
| +impl From<rusqlite::Error> for AppError { |
| + fn from(e: rusqlite::Error) -> Self { |
| + AppError(e) |
| + } |
| +} |
| + |
| +impl IntoResponse for AppError { |
| + fn into_response(self) -> Response { |
| + eprintln!("database error: {}", self.0); |
| + (StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong.\n").into_response() |
| + } |
| +} |
diff --git a/apps/rust/src/db.rs b/apps/rust/src/db.rs| new file mode 100644 |
| index 0000000..2b0acc3 |
| --- /dev/null |
| +++ b/apps/rust/src/db.rs |
| @@ -0,0 +1,137 @@ |
| +//! SQLite storage. One file in WAL mode; every query is scoped to a list. |
| + |
| +use std::path::Path; |
| +use std::sync::{Arc, Mutex}; |
| + |
| +use rusqlite::{Connection, OptionalExtension, params}; |
| + |
| +/// Schema steps, applied in order and tracked by `PRAGMA user_version`. |
| +/// Only ever append to this list: a migration may add, never drop or rewrite. |
| +const MIGRATIONS: &[&str] = &[ |
| + "CREATE TABLE lists ( |
| + id INTEGER PRIMARY KEY, |
| + token TEXT NOT NULL UNIQUE, |
| + created_at INTEGER NOT NULL DEFAULT (unixepoch()) |
| + ); |
| + CREATE TABLE todos ( |
| + id INTEGER PRIMARY KEY, |
| + list_id INTEGER NOT NULL REFERENCES lists(id), |
| + title TEXT NOT NULL, |
| + completed INTEGER NOT NULL DEFAULT 0 |
| + ); |
| + CREATE INDEX todos_by_list ON todos(list_id, id);", |
| +]; |
| + |
| +#[derive(Debug, Clone, PartialEq, Eq)] |
| +pub struct Todo { |
| + pub id: i64, |
| + pub title: String, |
| + pub completed: bool, |
| +} |
| + |
| +/// A shared handle to the one connection. SQLite serialises writers anyway, |
| +/// and every statement here takes microseconds, so a mutex is all it needs. |
| +#[derive(Clone)] |
| +pub struct Db(Arc<Mutex<Connection>>); |
| + |
| +impl Db { |
| + pub fn open(path: &Path) -> rusqlite::Result<Db> { |
| + let conn = Connection::open(path)?; |
| + conn.pragma_update(None, "journal_mode", "WAL")?; |
| + conn.pragma_update(None, "synchronous", "NORMAL")?; |
| + conn.pragma_update(None, "foreign_keys", "ON")?; |
| + conn.busy_timeout(std::time::Duration::from_secs(5))?; |
| + migrate(&conn)?; |
| + Ok(Db(Arc::new(Mutex::new(conn)))) |
| + } |
| + |
| + /// Runs `f` with the connection on tokio's blocking pool. |
| + pub async fn run<T, F>(&self, f: F) -> rusqlite::Result<T> |
| + where |
| + T: Send + 'static, |
| + F: FnOnce(&mut Connection) -> rusqlite::Result<T> + Send + 'static, |
| + { |
| + let db = self.0.clone(); |
| + tokio::task::spawn_blocking(move || { |
| + let mut conn = db.lock().unwrap_or_else(|e| e.into_inner()); |
| + f(&mut conn) |
| + }) |
| + .await |
| + .expect("database task panicked") |
| + } |
| +} |
| + |
| +fn migrate(conn: &Connection) -> rusqlite::Result<()> { |
| + let applied: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?; |
| + for (i, step) in MIGRATIONS.iter().enumerate().skip(applied as usize) { |
| + let tx = conn.unchecked_transaction()?; |
| + tx.execute_batch(step)?; |
| + tx.pragma_update(None, "user_version", i as i64 + 1)?; |
| + tx.commit()?; |
| + } |
| + Ok(()) |
| +} |
| + |
| +pub fn find_list(conn: &Connection, token: &str) -> rusqlite::Result<Option<i64>> { |
| + conn.query_row("SELECT id FROM lists WHERE token = ?1", [token], |r| r.get(0)) |
| + .optional() |
| +} |
| + |
| +pub fn create_list(conn: &Connection, token: &str) -> rusqlite::Result<i64> { |
| + conn.execute("INSERT INTO lists (token) VALUES (?1)", [token])?; |
| + Ok(conn.last_insert_rowid()) |
| +} |
| + |
| +pub fn todos(conn: &Connection, list: i64) -> rusqlite::Result<Vec<Todo>> { |
| + let mut stmt = conn.prepare_cached( |
| + "SELECT id, title, completed FROM todos WHERE list_id = ?1 ORDER BY id", |
| + )?; |
| + stmt.query_map([list], |r| { |
| + Ok(Todo { id: r.get(0)?, title: r.get(1)?, completed: r.get(2)? }) |
| + })? |
| + .collect() |
| +} |
| + |
| +pub fn add(conn: &Connection, list: i64, title: &str) -> rusqlite::Result<()> { |
| + conn.prepare_cached("INSERT INTO todos (list_id, title) VALUES (?1, ?2)")? |
| + .execute(params![list, title])?; |
| + Ok(()) |
| +} |
| + |
| +/// Each of these returns whether the item was in the list. |
| +pub fn toggle(conn: &Connection, list: i64, id: i64) -> rusqlite::Result<bool> { |
| + let n = conn |
| + .prepare_cached("UPDATE todos SET completed = NOT completed WHERE id = ?1 AND list_id = ?2")? |
| + .execute([id, list])?; |
| + Ok(n > 0) |
| +} |
| + |
| +pub fn rename(conn: &Connection, list: i64, id: i64, title: &str) -> rusqlite::Result<bool> { |
| + let n = conn |
| + .prepare_cached("UPDATE todos SET title = ?3 WHERE id = ?1 AND list_id = ?2")? |
| + .execute(params![id, list, title])?; |
| + Ok(n > 0) |
| +} |
| + |
| +pub fn destroy(conn: &Connection, list: i64, id: i64) -> rusqlite::Result<bool> { |
| + let n = conn |
| + .prepare_cached("DELETE FROM todos WHERE id = ?1 AND list_id = ?2")? |
| + .execute([id, list])?; |
| + Ok(n > 0) |
| +} |
| + |
| +/// Completes every item, or reopens them all if every one already was. |
| +pub fn toggle_all(conn: &Connection, list: i64) -> rusqlite::Result<()> { |
| + let any_active: bool = conn |
| + .prepare_cached("SELECT EXISTS (SELECT 1 FROM todos WHERE list_id = ?1 AND NOT completed)")? |
| + .query_row([list], |r| r.get(0))?; |
| + conn.prepare_cached("UPDATE todos SET completed = ?2 WHERE list_id = ?1")? |
| + .execute(params![list, any_active])?; |
| + Ok(()) |
| +} |
| + |
| +pub fn clear_completed(conn: &Connection, list: i64) -> rusqlite::Result<()> { |
| + conn.prepare_cached("DELETE FROM todos WHERE list_id = ?1 AND completed")? |
| + .execute([list])?; |
| + Ok(()) |
| +} |
diff --git a/apps/rust/src/lib.rs b/apps/rust/src/lib.rs| new file mode 100644 |
| index 0000000..c83132a |
| --- /dev/null |
| +++ b/apps/rust/src/lib.rs |
| @@ -0,0 +1,6 @@ |
| +//! todo-rust: the CARLOS bakeoff's TodoMVC, in Rust. The binary in |
| +//! `main.rs` parses flags and serves [`app::router`]. |
| + |
| +pub mod app; |
| +pub mod db; |
| +pub mod views; |
diff --git a/apps/rust/src/main.rs b/apps/rust/src/main.rs| new file mode 100644 |
| index 0000000..19ae709 |
| --- /dev/null |
| +++ b/apps/rust/src/main.rs |
| @@ -0,0 +1,134 @@ |
| +//! todo-rust: the CARLOS bakeoff's TodoMVC, in Rust. |
| +//! |
| +//! todo-rust --socket <path> --db <path> (on Carloku) |
| +//! todo-rust --listen 127.0.0.1:<port> --db <path> (locally) |
| + |
| +use todo_rust::{app, db}; |
| + |
| +use std::net::SocketAddr; |
| +use std::path::PathBuf; |
| +use std::process::ExitCode; |
| + |
| +use tokio::signal::unix::{SignalKind, signal}; |
| + |
| +/// The git short SHA, stamped in by `make` (see the Makefile). |
| +const VERSION: &str = match option_env!("APP_VERSION") { |
| + Some(v) => v, |
| + None => "dev", |
| +}; |
| + |
| +enum Bind { |
| + Socket(PathBuf), |
| + Listen(SocketAddr), |
| +} |
| + |
| +struct Args { |
| + bind: Bind, |
| + db: PathBuf, |
| +} |
| + |
| +fn parse_args() -> Result<Args, String> { |
| + let mut socket = None; |
| + let mut listen = None; |
| + let mut db = None; |
| + let mut it = std::env::args().skip(1); |
| + while let Some(flag) = it.next() { |
| + let mut value = || it.next().ok_or_else(|| format!("{flag} needs a value")); |
| + match flag.as_str() { |
| + "--socket" => socket = Some(PathBuf::from(value()?)), |
| + "--listen" => { |
| + let addr: SocketAddr = value()?.parse().map_err(|e| format!("--listen: {e}"))?; |
| + if !addr.ip().is_loopback() { |
| + return Err("--listen only binds a loopback address, e.g. 127.0.0.1:8080".into()); |
| + } |
| + listen = Some(addr); |
| + } |
| + "--db" => db = Some(PathBuf::from(value()?)), |
| + "--version" => { |
| + println!("{VERSION}"); |
| + std::process::exit(0); |
| + } |
| + other => return Err(format!("unknown argument {other}")), |
| + } |
| + } |
| + let bind = match (socket, listen) { |
| + (Some(path), None) => Bind::Socket(path), |
| + (None, Some(addr)) => Bind::Listen(addr), |
| + _ => return Err("pass exactly one of --socket <path> or --listen 127.0.0.1:<port>".into()), |
| + }; |
| + let db = db.ok_or("--db <path> is required")?; |
| + Ok(Args { bind, db }) |
| +} |
| + |
| +/// The origin browsers will send with our own form posts. |
| +fn own_origin(bind: &Bind) -> String { |
| + match bind { |
| + Bind::Listen(addr) => format!("http://{addr}"), |
| + Bind::Socket(_) => match std::env::var("CARLOS_HOST") { |
| + Ok(host) if !host.is_empty() => format!("https://{host}"), |
| + _ => { |
| + eprintln!("CARLOS_HOST is not set; every cross-origin POST will be refused"); |
| + "https://localhost".into() |
| + } |
| + }, |
| + } |
| +} |
| + |
| +async fn shutdown() { |
| + let mut term = signal(SignalKind::terminate()).expect("SIGTERM handler"); |
| + let mut int = signal(SignalKind::interrupt()).expect("SIGINT handler"); |
| + tokio::select! { |
| + _ = term.recv() => {} |
| + _ = int.recv() => {} |
| + } |
| +} |
| + |
| +#[tokio::main] |
| +async fn main() -> ExitCode { |
| + let args = match parse_args() { |
| + Ok(a) => a, |
| + Err(e) => { |
| + eprintln!("todo-rust: {e}"); |
| + return ExitCode::from(2); |
| + } |
| + }; |
| + let db = match db::Db::open(&args.db) { |
| + Ok(db) => db, |
| + Err(e) => { |
| + eprintln!("todo-rust: opening {}: {e}", args.db.display()); |
| + return ExitCode::FAILURE; |
| + } |
| + }; |
| + let state = app::AppState { db, origin: own_origin(&args.bind), version: VERSION }; |
| + let router = app::router(state); |
| + |
| + let served = match &args.bind { |
| + Bind::Listen(addr) => match tokio::net::TcpListener::bind(addr).await { |
| + Ok(l) => { |
| + eprintln!("todo-rust {VERSION} listening on http://{addr}"); |
| + axum::serve(l, router).with_graceful_shutdown(shutdown()).await |
| + } |
| + Err(e) => Err(e), |
| + }, |
| + Bind::Socket(path) => { |
| + // A socket left behind by an earlier run would make bind fail. |
| + let _ = std::fs::remove_file(path); |
| + match tokio::net::UnixListener::bind(path) { |
| + Ok(l) => { |
| + eprintln!("todo-rust {VERSION} listening on {}", path.display()); |
| + let r = axum::serve(l, router).with_graceful_shutdown(shutdown()).await; |
| + let _ = std::fs::remove_file(path); |
| + r |
| + } |
| + Err(e) => Err(e), |
| + } |
| + } |
| + }; |
| + match served { |
| + Ok(()) => ExitCode::SUCCESS, |
| + Err(e) => { |
| + eprintln!("todo-rust: {e}"); |
| + ExitCode::FAILURE |
| + } |
| + } |
| +} |
diff --git a/apps/rust/src/views.rs b/apps/rust/src/views.rs| new file mode 100644 |
| index 0000000..62c437c |
| --- /dev/null |
| +++ b/apps/rust/src/views.rs |
| @@ -0,0 +1,183 @@ |
| +//! HTML, built with maud, which escapes every interpolated value. |
| +//! |
| +//! The markup follows `shared/reference.html`. The extra controls a page |
| +//! needs without JavaScript (`.nojs`) are hidden unless `<noscript>` shows |
| +//! them, laid over the parts of the reference design they stand in for. |
| + |
| +use maud::{DOCTYPE, Markup, PreEscaped, html}; |
| + |
| +use crate::db::Todo; |
| + |
| +#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| +pub enum Filter { |
| + All, |
| + Active, |
| + Completed, |
| +} |
| + |
| +impl Filter { |
| + /// The `filter` form field. Anything unrecognised means all. |
| + pub fn from_field(s: Option<&str>) -> Filter { |
| + match s { |
| + Some("active") => Filter::Active, |
| + Some("completed") => Filter::Completed, |
| + _ => Filter::All, |
| + } |
| + } |
| + |
| + pub fn field(self) -> &'static str { |
| + match self { |
| + Filter::All => "all", |
| + Filter::Active => "active", |
| + Filter::Completed => "completed", |
| + } |
| + } |
| + |
| + pub fn path(self) -> &'static str { |
| + match self { |
| + Filter::All => "/", |
| + Filter::Active => "/active", |
| + Filter::Completed => "/completed", |
| + } |
| + } |
| + |
| + fn shows(self, todo: &Todo) -> bool { |
| + match self { |
| + Filter::All => true, |
| + Filter::Active => !todo.completed, |
| + Filter::Completed => todo.completed, |
| + } |
| + } |
| +} |
| + |
| +pub struct Page<'a> { |
| + pub todos: &'a [Todo], |
| + pub filter: Filter, |
| + pub editing: Option<i64>, |
| + pub language: &'a str, |
| +} |
| + |
| +const NOJS_CSS: &str = "\ |
| +.nojs{display:none}\ |
| +.main>form,.footer>form{display:contents}"; |
| + |
| +const NOSCRIPT_CSS: &str = "\ |
| +.nojs{display:block}\ |
| +.main .toggle-all-submit{position:absolute;top:-65px;left:0;width:45px;height:65px;font-size:0;cursor:pointer}\ |
| +.todo-list li .toggle-submit{position:absolute;top:0;bottom:0;left:0;width:40px;font-size:0;cursor:pointer}\ |
| +.todo-list li .edit-link{position:absolute;top:0;bottom:0;left:60px;right:60px;font-size:0}"; |
| + |
| +pub fn page(p: &Page) -> Markup { |
| + html! { |
| + (DOCTYPE) |
| + html lang="en" { |
| + head { |
| + meta charset="utf-8"; |
| + meta name="viewport" content="width=device-width, initial-scale=1"; |
| + title { "TodoMVC" } |
| + link rel="stylesheet" href="/static/base.css"; |
| + link rel="stylesheet" href="/static/index.css"; |
| + style { (PreEscaped(NOJS_CSS)) } |
| + noscript { style { (PreEscaped(NOSCRIPT_CSS)) } } |
| + script type="module" src="/static/app.js" {} |
| + } |
| + body { |
| + section.todoapp { |
| + header.header { |
| + h1 { "todos" } |
| + form method="post" action="/todos" { |
| + input type="hidden" name="filter" value=(p.filter.field()); |
| + input.new-todo name="title" placeholder="What needs to be done?" |
| + autocomplete="off" autofocus[p.editing.is_none()]; |
| + } |
| + } |
| + (list(p)) |
| + } |
| + footer.info { |
| + p { "Double-click to edit a todo" } |
| + p { |
| + "Created by " a href="https://bakeoff.bfj.oncarlos.com" { "the CARLOS bakeoff" } |
| + " · " (p.language) |
| + } |
| + p { "Part of " a href="http://todomvc.com" { "TodoMVC" } } |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +/// `section.main` and `footer.footer`: everything that changes after an |
| +/// action. JavaScript swaps this fragment in place. |
| +pub fn list(p: &Page) -> Markup { |
| + let total = p.todos.len(); |
| + let active = p.todos.iter().filter(|t| !t.completed).count(); |
| + let completed = total - active; |
| + let filter = p.filter.field(); |
| + html! { |
| + section.main hidden[total == 0] { |
| + input #toggle-all.toggle-all type="checkbox" checked[total > 0 && active == 0]; |
| + label for="toggle-all" { "Mark all as complete" } |
| + form method="post" action="/todos/toggle-all" { |
| + input type="hidden" name="filter" value=(filter); |
| + button.nojs.toggle-all-submit data-action="toggle-all" { "Mark all as complete" } |
| + } |
| + ul.todo-list { |
| + @for todo in p.todos.iter().filter(|t| p.filter.shows(t)) { |
| + (item(todo, p.filter, p.editing == Some(todo.id))) |
| + } |
| + } |
| + } |
| + footer.footer hidden[total == 0] { |
| + span.todo-count { |
| + strong { (active) } (if active == 1 { " item left" } else { " items left" }) |
| + } |
| + ul.filters { |
| + (filter_link(Filter::All, "All", p.filter)) " " |
| + (filter_link(Filter::Active, "Active", p.filter)) " " |
| + (filter_link(Filter::Completed, "Completed", p.filter)) |
| + } |
| + form method="post" action="/todos/clear-completed" { |
| + input type="hidden" name="filter" value=(filter); |
| + button.clear-completed hidden[completed == 0] { "Clear completed" } |
| + } |
| + } |
| + } |
| +} |
| + |
| +fn filter_link(f: Filter, text: &str, current: Filter) -> Markup { |
| + html! { li { a class=[(f == current).then_some("selected")] href=(f.path()) { (text) } } } |
| +} |
| + |
| +fn item(todo: &Todo, filter: Filter, editing: bool) -> Markup { |
| + let id = todo.id; |
| + let field = filter.field(); |
| + let class = match (todo.completed, editing) { |
| + (false, false) => None, |
| + (true, false) => Some("completed"), |
| + (false, true) => Some("editing"), |
| + (true, true) => Some("completed editing"), |
| + }; |
| + html! { |
| + li class=[class] data-id=(id) { |
| + div.view { |
| + input.toggle type="checkbox" checked[todo.completed]; |
| + label { (todo.title) } |
| + form method="post" action={ "/todos/" (id) "/toggle" } { |
| + input type="hidden" name="filter" value=(field); |
| + button.nojs.toggle-submit data-action="toggle" { |
| + (if todo.completed { "Mark as active" } else { "Mark as complete" }) |
| + } |
| + } |
| + a.nojs.edit-link data-action="edit" href={ (filter.path()) "?edit=" (id) } { "Edit" } |
| + form method="post" action={ "/todos/" (id) "/destroy" } { |
| + input type="hidden" name="filter" value=(field); |
| + button.destroy aria-label="Delete" {} |
| + } |
| + } |
| + form method="post" action={ "/todos/" (id) "/edit" } { |
| + input type="hidden" name="filter" value=(field); |
| + input.edit name="title" value=(todo.title) autocomplete="off" autofocus[editing]; |
| + } |
| + } |
| + } |
| +} |
diff --git a/apps/rust/static/app.js b/apps/rust/static/app.js| new file mode 100644 |
| index 0000000..994fcdd |
| --- /dev/null |
| +++ b/apps/rust/static/app.js |
| @@ -0,0 +1,97 @@ |
| +// Progressive enhancement for the server-rendered TodoMVC. Every action is |
| +// still the page's own form POST; this script sends it with fetch, asks for |
| +// the list fragment back (X-Todo-Fragment), and swaps it in, so the page |
| +// behaves like the reference TodoMVC without full page loads. |
| + |
| +let queue = Promise.resolve(); |
| + |
| +// Sends one action. Actions queue so they reach the server in order. |
| +function send(action, body) { |
| + queue = queue.then(async () => { |
| + const res = await fetch(action, { |
| + method: 'POST', |
| + headers: { 'X-Todo-Fragment': '1' }, |
| + body: new URLSearchParams(body), |
| + }); |
| + if (!res.ok) throw new Error(`${action} answered ${res.status}`); |
| + swap(await res.text()); |
| + }).catch((err) => { |
| + console.error(err); |
| + location.reload(); |
| + }); |
| + return queue; |
| +} |
| + |
| +// Replaces section.main and footer.footer with the server's fresh copies. |
| +function swap(html) { |
| + const tpl = document.createElement('template'); |
| + tpl.innerHTML = html; |
| + for (const sel of ['section.main', 'footer.footer']) { |
| + const fresh = tpl.content.querySelector(sel); |
| + const old = document.querySelector(`.todoapp ${sel}`); |
| + if (fresh && old) old.replaceWith(fresh); |
| + } |
| +} |
| + |
| +// The form a control belongs to, as fields to send. |
| +const fields = (form) => new FormData(form); |
| + |
| +document.addEventListener('submit', (e) => { |
| + const form = e.target; |
| + e.preventDefault(); |
| + const input = form.querySelector('.new-todo'); |
| + const body = fields(form); |
| + if (input) input.value = ''; |
| + send(form.action, body); |
| +}); |
| + |
| +document.addEventListener('change', (e) => { |
| + const el = e.target; |
| + if (el.matches('.todo-list .toggle')) { |
| + const form = el.closest('li').querySelector('form[action$="/toggle"]'); |
| + send(form.action, fields(form)); |
| + } else if (el.matches('#toggle-all')) { |
| + const form = document.querySelector('form[action="/todos/toggle-all"]'); |
| + send(form.action, fields(form)); |
| + } |
| +}); |
| + |
| +// Editing: double-click a label, Enter or blur saves, Escape cancels. |
| +document.addEventListener('dblclick', (e) => { |
| + const label = e.target.closest('.todo-list li label'); |
| + if (!label) return; |
| + const li = label.closest('li'); |
| + const input = li.querySelector('.edit'); |
| + li.classList.add('editing'); |
| + input.dataset.original = input.value; |
| + input.focus(); |
| + input.setSelectionRange(input.value.length, input.value.length); |
| +}); |
| + |
| +function finishEditing(input, save) { |
| + const li = input.closest('li'); |
| + if (!li || !li.classList.contains('editing') || input.dataset.done) return; |
| + input.dataset.done = '1'; |
| + if (save) { |
| + send(input.form.action, fields(input.form)); |
| + } else { |
| + input.value = input.dataset.original ?? input.defaultValue; |
| + li.classList.remove('editing'); |
| + delete input.dataset.done; |
| + } |
| +} |
| + |
| +document.addEventListener('keydown', (e) => { |
| + const input = e.target; |
| + if (!input.matches('.todo-list .edit')) return; |
| + if (e.key === 'Enter') { |
| + e.preventDefault(); |
| + finishEditing(input, true); |
| + } else if (e.key === 'Escape') { |
| + finishEditing(input, false); |
| + } |
| +}); |
| + |
| +document.addEventListener('focusout', (e) => { |
| + if (e.target.matches('.todo-list .edit')) finishEditing(e.target, true); |
| +}); |
diff --git a/apps/rust/tests/http.rs b/apps/rust/tests/http.rs| new file mode 100644 |
| index 0000000..eb17388 |
| --- /dev/null |
| +++ b/apps/rust/tests/http.rs |
| @@ -0,0 +1,230 @@ |
| +//! Drives the router in-process, the way the contract describes it. |
| + |
| +use axum::{ |
| + Router, |
| + body::Body, |
| + http::{Request, StatusCode, header}, |
| +}; |
| +use http_body_util::BodyExt; |
| +use todo_rust::{app, db::Db}; |
| +use tower::ServiceExt; |
| + |
| +const ORIGIN: &str = "http://127.0.0.1:9"; |
| + |
| +struct Harness { |
| + router: Router, |
| + _dir: TempDir, |
| +} |
| + |
| +/// A throwaway directory under $TMPDIR, removed on drop. |
| +struct TempDir(std::path::PathBuf); |
| + |
| +impl Drop for TempDir { |
| + fn drop(&mut self) { |
| + let _ = std::fs::remove_dir_all(&self.0); |
| + } |
| +} |
| + |
| +fn harness() -> Harness { |
| + let mut name = [0u8; 8]; |
| + getrandom::fill(&mut name).unwrap(); |
| + let dir = std::env::temp_dir().join(format!("todo-rust-test-{:x}", u64::from_ne_bytes(name))); |
| + std::fs::create_dir_all(&dir).unwrap(); |
| + let db = Db::open(&dir.join("test.db")).unwrap(); |
| + let router = app::router(app::AppState { db, origin: ORIGIN.into(), version: "test" }); |
| + Harness { router, _dir: TempDir(dir) } |
| +} |
| + |
| +struct Reply { |
| + status: StatusCode, |
| + location: Option<String>, |
| + cookie: Option<String>, |
| + body: String, |
| +} |
| + |
| +impl Harness { |
| + async fn send(&self, req: Request<Body>) -> Reply { |
| + let res = self.router.clone().oneshot(req).await.unwrap(); |
| + let header = |name| res.headers().get(name).map(|v: &header::HeaderValue| v.to_str().unwrap().to_owned()); |
| + let location = header(header::LOCATION); |
| + let cookie = header(header::SET_COOKIE); |
| + let status = res.status(); |
| + let body = String::from_utf8(res.into_body().collect().await.unwrap().to_bytes().to_vec()).unwrap(); |
| + Reply { status, location, cookie, body } |
| + } |
| + |
| + async fn get(&self, path: &str, cookie: Option<&str>) -> Reply { |
| + let mut req = Request::get(path); |
| + if let Some(c) = cookie { |
| + req = req.header(header::COOKIE, c); |
| + } |
| + self.send(req.body(Body::empty()).unwrap()).await |
| + } |
| + |
| + async fn post(&self, path: &str, cookie: &str, form: &str) -> Reply { |
| + let req = Request::post(path) |
| + .header(header::COOKIE, cookie) |
| + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") |
| + .body(Body::from(form.to_owned())) |
| + .unwrap(); |
| + self.send(req).await |
| + } |
| + |
| + /// A new visitor: their cookie, as a `Cookie` header value. |
| + async fn visitor(&self) -> String { |
| + let reply = self.get("/", None).await; |
| + reply.cookie.unwrap().split(';').next().unwrap().to_owned() |
| + } |
| +} |
| + |
| +fn ids(body: &str) -> Vec<String> { |
| + body.split("data-id=\"").skip(1).map(|s| s.split('"').next().unwrap().to_owned()).collect() |
| +} |
| + |
| +#[tokio::test] |
| +async fn first_visit_sets_a_list_cookie() { |
| + let h = harness(); |
| + let reply = h.get("/", None).await; |
| + assert_eq!(reply.status, StatusCode::OK); |
| + let cookie = reply.cookie.unwrap(); |
| + assert!(cookie.contains("HttpOnly") && cookie.contains("SameSite=Lax") && cookie.contains("Path=/")); |
| + assert!(!cookie.contains("Secure"), "plain http should not get Secure"); |
| + let token = cookie.split(';').next().unwrap().split('=').nth(1).unwrap(); |
| + assert_eq!(token.len(), 32); |
| +} |
| + |
| +#[tokio::test] |
| +async fn add_trims_and_redirects_to_the_filter() { |
| + let h = harness(); |
| + let me = h.visitor().await; |
| + let reply = h.post("/todos", &me, "title=++buy+milk++&filter=active").await; |
| + assert_eq!(reply.status, StatusCode::SEE_OTHER); |
| + assert_eq!(reply.location.as_deref(), Some("/active")); |
| + let page = h.get("/", Some(&me)).await.body; |
| + assert!(page.contains("<label>buy milk</label>")); |
| + assert!(page.contains("<strong>1</strong> item left")); |
| + |
| + let reply = h.post("/todos", &me, "title=+++&filter=bogus").await; |
| + assert_eq!(reply.location.as_deref(), Some("/")); |
| + assert_eq!(ids(&h.get("/", Some(&me)).await.body).len(), 1); |
| +} |
| + |
| +#[tokio::test] |
| +async fn titles_are_escaped_and_kept_exactly() { |
| + let h = harness(); |
| + let me = h.visitor().await; |
| + h.post("/todos", &me, "title=%3Cimg+src%3Dx%3E+%26+na%C3%AFve+%F0%9F%9A%80").await; |
| + let page = h.get("/", Some(&me)).await.body; |
| + assert!(page.contains("<label><img src=x> & naïve 🚀</label>")); |
| + assert!(!page.contains("<img")); |
| +} |
| + |
| +#[tokio::test] |
| +async fn toggle_toggle_all_and_clear_completed() { |
| + let h = harness(); |
| + let me = h.visitor().await; |
| + for t in ["a", "b", "c"] { |
| + h.post("/todos", &me, &format!("title={t}")).await; |
| + } |
| + let id = ids(&h.get("/", Some(&me)).await.body)[0].clone(); |
| + h.post(&format!("/todos/{id}/toggle"), &me, "filter=all").await; |
| + assert_eq!(ids(&h.get("/completed", Some(&me)).await.body), vec![id.clone()]); |
| + assert_eq!(ids(&h.get("/active", Some(&me)).await.body).len(), 2); |
| + |
| + h.post("/todos/toggle-all", &me, "filter=all").await; |
| + assert_eq!(ids(&h.get("/active", Some(&me)).await.body).len(), 0); |
| + h.post("/todos/toggle-all", &me, "filter=all").await; |
| + assert_eq!(ids(&h.get("/completed", Some(&me)).await.body).len(), 0); |
| + |
| + h.post(&format!("/todos/{id}/toggle"), &me, "filter=all").await; |
| + h.post("/todos/clear-completed", &me, "filter=completed").await; |
| + assert_eq!(ids(&h.get("/", Some(&me)).await.body).len(), 2); |
| +} |
| + |
| +#[tokio::test] |
| +async fn edit_renames_and_empty_title_deletes() { |
| + let h = harness(); |
| + let me = h.visitor().await; |
| + h.post("/todos", &me, "title=old").await; |
| + let id = ids(&h.get("/", Some(&me)).await.body)[0].clone(); |
| + |
| + let page = h.get(&format!("/?edit={id}"), Some(&me)).await.body; |
| + assert!(page.contains(&format!("class=\"editing\" data-id=\"{id}\""))); |
| + assert!(page.contains(&format!("action=\"/todos/{id}/edit\""))); |
| + |
| + h.post(&format!("/todos/{id}/edit"), &me, "title=+new+&filter=all").await; |
| + assert!(h.get("/", Some(&me)).await.body.contains("<label>new</label>")); |
| + h.post(&format!("/todos/{id}/edit"), &me, "title=+&filter=all").await; |
| + assert!(ids(&h.get("/", Some(&me)).await.body).is_empty()); |
| +} |
| + |
| +#[tokio::test] |
| +async fn another_visitors_id_is_404_and_untouched() { |
| + let h = harness(); |
| + let me = h.visitor().await; |
| + let them = h.visitor().await; |
| + h.post("/todos", &me, "title=mine").await; |
| + let id = ids(&h.get("/", Some(&me)).await.body)[0].clone(); |
| + for action in ["toggle", "edit", "destroy"] { |
| + let reply = h.post(&format!("/todos/{id}/{action}"), &them, "title=x&filter=all").await; |
| + assert_eq!(reply.status, StatusCode::NOT_FOUND, "{action}"); |
| + } |
| + assert_eq!(h.post("/todos/nope/toggle", &me, "filter=all").await.status, StatusCode::NOT_FOUND); |
| + let page = h.get("/", Some(&me)).await.body; |
| + assert!(page.contains("<label>mine</label>") && !page.contains("class=\"completed\"")); |
| +} |
| + |
| +#[tokio::test] |
| +async fn garbled_or_unknown_cookie_gets_a_fresh_list() { |
| + let h = harness(); |
| + for cookie in ["todo_list=%%%", "todo_list=00000000000000000000000000000000", "other=1"] { |
| + let reply = h.get("/", Some(cookie)).await; |
| + assert_eq!(reply.status, StatusCode::OK); |
| + assert!(reply.cookie.is_some(), "{cookie}"); |
| + assert!(ids(&reply.body).is_empty()); |
| + } |
| +} |
| + |
| +#[tokio::test] |
| +async fn foreign_origin_is_403_and_own_origin_is_fine() { |
| + let h = harness(); |
| + let me = h.visitor().await; |
| + let post = |origin: &'static str| { |
| + Request::post("/todos") |
| + .header(header::COOKIE, me.clone()) |
| + .header(header::ORIGIN, origin) |
| + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") |
| + .body(Body::from("title=x")) |
| + .unwrap() |
| + }; |
| + assert_eq!(h.send(post("https://evil.example")).await.status, StatusCode::FORBIDDEN); |
| + assert_eq!(h.send(post("null")).await.status, StatusCode::FORBIDDEN); |
| + assert!(ids(&h.get("/", Some(&me)).await.body).is_empty()); |
| + assert_eq!(h.send(post(ORIGIN)).await.status, StatusCode::SEE_OTHER); |
| +} |
| + |
| +#[tokio::test] |
| +async fn fragment_header_returns_the_list() { |
| + let h = harness(); |
| + let me = h.visitor().await; |
| + let req = Request::post("/todos") |
| + .header(header::COOKIE, me) |
| + .header("X-Todo-Fragment", "1") |
| + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") |
| + .body(Body::from("title=hello&filter=all")) |
| + .unwrap(); |
| + let reply = h.send(req).await; |
| + assert_eq!(reply.status, StatusCode::OK); |
| + assert!(reply.body.starts_with("<section class=\"main\">")); |
| + assert!(reply.body.contains("<label>hello</label>")); |
| +} |
| + |
| +#[tokio::test] |
| +async fn static_files_health_and_version() { |
| + let h = harness(); |
| + assert_eq!(h.get("/static/base.css", None).await.body, include_str!("../../../shared/base.css")); |
| + assert_eq!(h.get("/static/index.css", None).await.body, include_str!("../../../shared/index.css")); |
| + assert_eq!(h.get("/healthz", None).await.status, StatusCode::OK); |
| + assert_eq!(h.get("/api/version", None).await.body, "test"); |
| + assert_eq!(h.get("/nope", None).await.status, StatusCode::NOT_FOUND); |
| +} |