feat: rust builder output

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 906035d430796a7624de71e5de42a433073b4d5c parent 72c653cparent 6a50716
14 files changed, +2142 −0
  • apps/rust/AGENTS.md +26 −0
  • apps/rust/Cargo.lock +778 −0
  • apps/rust/Cargo.toml +28 −0
  • apps/rust/DEPS +7 −0
  • apps/rust/Makefile +45 −0
  • apps/rust/README.md +70 −0
  • apps/rust/STATUS.md +47 −0
  • apps/rust/src/app.rs +322 −0
  • apps/rust/src/db.rs +137 −0
  • apps/rust/src/lib.rs +6 −0
  • apps/rust/src/main.rs +138 −0
  • apps/rust/src/views.rs +183 −0
  • apps/rust/static/app.js +125 −0
  • apps/rust/tests/http.rs +230 −0
diff --git a/apps/rust/AGENTS.md b/apps/rust/AGENTS.md
new file mode 100644
index 0000000..761d669
--- /dev/null
+++ b/apps/rust/AGENTS.md
@@ -0,0 +1,26 @@
+# 🤖 AGENTS.md: todo-rust
+
+The one rule: **the page works with JavaScript off.** Every action is a
+plain HTML form POST answered with a 303. JavaScript only makes the same
+requests without a page load; it never adds behaviour the server lacks.
+
+## Tenancy
+
+- **One instance for the whole app** (`todo-rust.bfj.oncarlos.com`, one
+ SQLite file).
+- **Each visitor's list is kept separately by an anonymous cookie**
+ (`todo_list`: 128 random bits, hex, `HttpOnly; SameSite=Lax; Path=/`,
+ `Secure` over https). Every query is scoped to the list that cookie
+ names; an id from another list answers 404.
+- **No sign-in.** Losing the cookie loses the list.
+- **Trust class C.** Todo text is stored as plain text in SQLite. That is
+ a deliberate choice for a demo app with anonymous lists, written down
+ here so nobody mistakes it for an oversight.
+
+## Working here
+
+- Work only in `apps/rust/`. `BRIEF.md` and `CONTRACT.md` at the repo
+ root are binding.
+- Servers bind `127.0.0.1` only. Scratch goes in `mktemp -d -p "$TMPDIR"`.
+- Migrations only add (`src/db.rs`, keyed by `PRAGMA user_version`).
+- Commits end with a `Co-Authored-By:` trailer naming the model.
diff --git a/apps/rust/Cargo.lock b/apps/rust/Cargo.lock
new file mode 100644
index 0000000..98b54f3
--- /dev/null
+++ b/apps/rust/Cargo.lock
@@ -0,0 +1,778 @@
+# 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 = "libmimalloc-sys"
+version = "0.1.49"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
+dependencies = [
+ "cc",
+]
+
+[[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 = "mimalloc"
+version = "0.1.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
+dependencies = [
+ "libmimalloc-sys",
+]
+
+[[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",
+ "mimalloc",
+ "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..0ac0263
--- /dev/null
+++ b/apps/rust/Cargo.toml
@@ -0,0 +1,28 @@
+[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"
+mimalloc = "0.1.52"
+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..7651027
--- /dev/null
+++ b/apps/rust/DEPS
@@ -0,0 +1,7 @@
+axum
+getrandom
+maud
+mimalloc
+rusqlite
+serde
+tokio
diff --git a/apps/rust/Makefile b/apps/rust/Makefile
new file mode 100644
index 0000000..20ab24e
--- /dev/null
+++ b/apps/rust/Makefile
@@ -0,0 +1,45 @@
+# todo-rust. Cargo's build output lives in .cache/target and Zig's compiled
+# libraries (musl, compiler-rt) in .cache/zig, both git-ignored; downloaded
+# crate sources stay in ~/.cargo/registry.
+SHELL := /bin/bash
+TARGET_DIR := .cache/target
+ZIG_CACHE := $(CURDIR)/.cache/zig
+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) \
+ ZIG_GLOBAL_CACHE_DIR=$(ZIG_CACHE) ZIG_LOCAL_CACHE_DIR=$(ZIG_CACHE) 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) $(ZIG_CACHE)
+
+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/README.md b/apps/rust/README.md
new file mode 100644
index 0000000..322a565
--- /dev/null
+++ b/apps/rust/README.md
@@ -0,0 +1,70 @@
+# todo-rust
+
+TodoMVC for the CARLOS bakeoff, in Rust. The server renders every page
+and handles every action as a plain form POST, so the app works with
+JavaScript off. A small ES module (`static/app.js`) sends the same POSTs
+with `fetch` and swaps in the updated list, so with JavaScript on it
+behaves like the reference TodoMVC.
+
+Live at [https://todo-rust.bfj.oncarlos.com](https://todo-rust.bfj.oncarlos.com).
+
+## The stack, and why
+
+| Piece | Choice | Why |
+|---|---|---|
+| HTTP server and router | [axum](https://docs.rs/axum) 0.8 on tokio (hyper underneath) | The default choice for a Rust web service today. It serves a unix socket and a TCP port through the same `axum::serve`, and graceful shutdown on SIGTERM is one call |
+| Templates | [maud](https://maud.lambda.xyz) | HTML written as Rust macros and checked at compile time. Every interpolated value is escaped unless you opt out, which is what you want for user-typed titles |
+| SQLite | [rusqlite](https://docs.rs/rusqlite) with `bundled` | SQLite is compiled from source into the binary, so there is no shared library to find at run time. One connection behind a mutex, used from tokio's blocking pool |
+| Allocator | [mimalloc](https://docs.rs/mimalloc) | musl's own allocator is slow when many threads allocate; mimalloc is the usual fix for static musl builds |
+| Randomness | [getrandom](https://docs.rs/getrandom) | 128-bit cookie tokens straight from the OS |
+| Forms and query strings | [serde](https://serde.rs) (through axum's `Form` and `Query`) | |
+
+Flags are parsed by hand (there are three), and cookies too (there is
+one). The shared CSS files are compiled into the binary with
+`include_str!`, so they are served byte for byte.
+
+Lists are kept apart by a `todo_list` cookie holding 128 random bits.
+A cookie the app didn't issue gets a fresh list. Every query names the
+list, so an id from someone else's list is a 404. A POST whose `Origin`
+isn't the app's own is a 403. See `AGENTS.md` for the tenancy rules.
+
+### Layout
+
+- `src/main.rs`: flags, socket or TCP listener, SIGTERM.
+- `src/app.rs`: routes and handlers, including `GET /`.
+- `src/views.rs`: the markup, following `shared/reference.html`.
+- `src/db.rs`: schema, migrations (additive, tracked by `PRAGMA user_version`) and queries.
+- `static/app.js`: the JavaScript layer.
+- `tests/http.rs`: tests that drive the router in-process.
+
+### Without JavaScript
+
+The reference markup has checkboxes and a double-click to edit, neither
+of which can post a form. The page adds a submit button over each
+checkbox (and over the toggle-all arrow) and a link over each title that
+opens `?edit=<id>`. They're transparent and sit exactly on the parts of
+the design they stand in for, so the page looks the same either way. A
+`<noscript>` stylesheet turns them on; with JavaScript they stay
+`display: none`.
+
+## Build, run, test, deploy
+
+Toolchains come from the repo: the Makefile sources `scripts/env.sh`
+itself. Build output goes in `.cache/target` and `dist/`.
+
+```sh
+make build # dist/app for this machine
+make build-arm64 # dist/app-linux-arm64, static (cargo zigbuild, musl)
+make test # cargo test
+dist/app --listen 127.0.0.1:8080 --db "$TMPDIR/rust.db"
+../../scripts/test-app.sh http://127.0.0.1:8080 "$TMPDIR/rust-suite.json"
+../../scripts/boot-check.sh dist/app-linux-arm64
+make deploy MESSAGE="what changed"
+```
+
+`make clean` removes `dist/` and this crate's own compiled files.
+`make clean-deps` removes all of `.cache/target`, so the next build
+recompiles every dependency (the downloaded sources in `~/.cargo` stay).
+
+The version `/api/version` reports is the git short SHA at build time,
+stamped in through the `APP_VERSION` environment variable.
diff --git a/apps/rust/STATUS.md b/apps/rust/STATUS.md
new file mode 100644
index 0000000..b832ba8
--- /dev/null
+++ b/apps/rust/STATUS.md
@@ -0,0 +1,47 @@
+LIVE https://todo-rust.bfj.oncarlos.com f78a916
+
+## What's there
+
+- Rust: axum 0.8 on tokio, maud templates, rusqlite with bundled SQLite,
+ mimalloc. A static linux-arm64 musl binary of about 3 MB, built with
+ `cargo zigbuild`.
+- `make test`: 10 in-process HTTP tests, all passing.
+- Shared suite on a local run: 67 passed, 3 skipped (the skips are the
+ JS-only tests in the JS-off run). Against
+ [https://todo-rust.bfj.oncarlos.com](https://todo-rust.bfj.oncarlos.com),
+ 67 passed, 3 skipped after the review-fix deploy (`f78a916`).
+- `scripts/boot-check.sh dist/app-linux-arm64`: healthz 200, version 200,
+ clean exit on SIGTERM.
+
+## Worth knowing
+
+On the first live run, one test failed: a cross-origin POST got 502
+from the edge instead of 403. It reproduced reliably: 19 of 60
+cross-origin POSTs and 13 of 40 POSTs to an unknown path came back 502.
+Both responses were sent before the app had read the request body, and
+that drops the connection to the edge. The app now reads the whole body
+(up to 1 MiB) before it answers anything. Afterwards, 120 of 120 of
+those requests answered correctly (403 and 404). Any other app that
+rejects a request without reading its body may hit the same thing on
+Carloku.
+
+## Review fixes (`f78a916`)
+
+1. **An edit was lost when another action's response arrived.**
+ `static/app.js` used to replace the whole list on every response. If
+ an item is being edited and its edit hasn't been sent yet, the list is
+ now patched around that item instead. Its draft, focus and edit state
+ stay, and only its view (label, checkbox) is refreshed. Checked with a
+ Playwright script that delayed POST responses by 1.5 s: toggle one
+ item, start editing another and type a draft. When the toggle response
+ arrived, the toggle was applied and the draft, focus and edit state
+ were all still there. Pressing Enter then saved the draft.
+2. **`clean-deps` left Zig's compiled libraries in its shared cache.**
+ The Makefile now points `ZIG_GLOBAL_CACHE_DIR` and
+ `ZIG_LOCAL_CACHE_DIR` at `apps/rust/.cache/zig`, and `clean-deps`
+ removes that directory. A cold `make build-arm64` after `clean-deps`
+ took 1 min 29 s and refilled `.cache/zig` (79 MB).
+
+## Review findings not fixed
+
+None.
diff --git a/apps/rust/src/app.rs b/apps/rust/src/app.rs
new file mode 100644
index 0000000..f4a4d66
--- /dev/null
+++ b/apps/rust/src/app.rs
@@ -0,0 +1,322 @@
+//! 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)
+}
+
+/// The largest request body we read: a 10,000-character title is ~90 KB
+/// form-encoded at worst.
+const BODY_LIMIT: usize = 1 << 20;
+
+/// Reads the whole request body before anything answers, then refuses a
+/// POST whose `Origin` is present and isn't ours.
+///
+/// Reading first matters behind Carloku's edge: a response sent while the
+/// body is still unread (a 403 here, a 404 for an unknown route) makes
+/// hyper drop the connection, and the edge reports that as a 502.
+///
+/// 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 {
+ let (parts, body) = req.into_parts();
+ let Ok(bytes) = axum::body::to_bytes(body, BODY_LIMIT).await else {
+ return StatusCode::PAYLOAD_TOO_LARGE.into_response();
+ };
+ if parts.method == Method::POST {
+ if let Some(origin) = parts.headers.get(header::ORIGIN) {
+ if origin.as_bytes() != s.origin.as_bytes() {
+ return StatusCode::FORBIDDEN.into_response();
+ }
+ }
+ }
+ next.run(Request::from_parts(parts, axum::body::Body::from(bytes))).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..3cb0343
--- /dev/null
+++ b/apps/rust/src/main.rs
@@ -0,0 +1,138 @@
+//! 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};
+
+// musl's allocator is slow under many threads; mimalloc is a drop-in fix.
+#[global_allocator]
+static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
+
+/// 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..1bff4c6
--- /dev/null
+++ b/apps/rust/static/app.js
@@ -0,0 +1,125 @@
+// 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.
+// An edit still in progress (not yet sent) survives: its li stays in the
+// page, focus and draft included, and the fresh items go around it.
+function swap(html) {
+ const tpl = document.createElement('template');
+ tpl.innerHTML = html;
+ const freshMain = tpl.content.querySelector('section.main');
+ const freshFooter = tpl.content.querySelector('footer.footer');
+ const main = document.querySelector('.todoapp section.main');
+ const footer = document.querySelector('.todoapp footer.footer');
+ if (!freshMain || !freshFooter || !main || !footer) return;
+ footer.replaceWith(freshFooter);
+
+ const editing = [...main.querySelectorAll('.todo-list li.editing')]
+ .find((li) => !li.querySelector('.edit').dataset.done);
+ const keep = editing && freshMain.querySelector(`.todo-list li[data-id="${editing.dataset.id}"]`);
+ if (!keep) {
+ // Gone from this view: drop the edit without the blur saving it.
+ if (editing) editing.querySelector('.edit').dataset.done = '1';
+ main.replaceWith(freshMain);
+ return;
+ }
+
+ // Patch main in place so the editing li is never detached.
+ main.hidden = freshMain.hidden;
+ const list = main.querySelector('.todo-list');
+ for (const el of [...main.children]) if (el !== list) el.remove();
+ for (const el of [...freshMain.children]) if (!el.matches('.todo-list')) list.before(el);
+ for (const li of [...list.children]) if (li !== editing) li.remove();
+ editing.querySelector('.view').replaceWith(keep.querySelector('.view'));
+ editing.classList.toggle('completed', keep.classList.contains('completed'));
+ let before = true;
+ for (const li of [...freshMain.querySelector('.todo-list').children]) {
+ if (li === keep) before = false;
+ else if (before) editing.before(li);
+ else list.append(li);
+ }
+}
+
+// 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>&lt;img src=x&gt; &amp; 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);
+}