From a490de2c3f6800041be398fc640ded5bca89aeb4 Mon Sep 17 00:00:00 2001 From: Blake Ridgway Date: Wed, 19 Aug 2026 01:30:58 +0000 Subject: [PATCH] TRUSS-2 working on moving to Rust --- .gitignore | 15 + Cargo.lock | 746 +++++++++++++++++++++ Cargo.toml | 14 + README.md | 45 ++ TODO-msp.md | 21 +- __pycache__/build.cpython-314.pyc | Bin 29516 -> 0 bytes build.py | 601 ----------------- output/index.html | 789 ---------------------- src/main.rs | 1037 +++++++++++++++++++++++++++++ templates/board.html | 10 +- 10 files changed, 1881 insertions(+), 1397 deletions(-) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md delete mode 100644 __pycache__/build.cpython-314.pyc delete mode 100644 build.py delete mode 100644 output/index.html create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..528acb3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Rust build output +/target + +# Generated static output (dynamic server mode is the intended workflow) +/output + +# Python artifacts left over from the previous implementation +__pycache__/ +*.py[cod] + +# Editor/OS noise +.DS_Store +.idea/ +.vscode/ + diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..29637bc --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,746 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[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_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[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", + "tracing", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[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 = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[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 = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[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 = "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.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +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.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memo-map" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minijinja" +version = "2.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86886cf6dbf4e614b19c9a1eec9775f021869d7eadde0fc73921a81b90c9b4c9" +dependencies = [ + "memo-map", + "serde", + "serde_json", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +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 = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "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 = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[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 = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[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 = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[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.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_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 = "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.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[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 = "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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +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 = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "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.3", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "truss" +version = "0.1.0" +dependencies = [ + "axum", + "minijinja", + "regex", + "serde", + "serde_json", + "tokio", + "uuid", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "uuid" +version = "1.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[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.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +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", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..57c028a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "truss" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = "0.8" +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v4"] } +regex = "1" +minijinja = { version = "2", features = ["loader", "json"] } + diff --git a/README.md b/README.md new file mode 100644 index 0000000..f89036b --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# Truss + +A small, dynamically-rendered kanban server for the Arcline TODO board. + +The server reads `TODO.md` and the per-track board files, renders the board with +Rust + Axum + Minijinja, and persists card changes back to the same Markdown files. + +## Quickstart + +```bash +cargo run +``` + +Then open . + +## Board files + +- `TODO.md` is the master board. +- `TODO-msp.md` is the MSP / IT track. +- `TODO-os.md` is the OS / open-source track. + +The MSP and OS board files are auto-generated from `TODO.md` on startup if they +do not already exist. + +## HTTP API + +| Method | Route | Description | +| ------ | ----- | ----------- | +| `GET` | `/` | Board index | +| `GET` | `/{board}` | Rendered board (`msp` or `os`) | +| `GET` | `/api/{board}/board` | Board data as JSON | +| `POST` | `/api/{board}/cards` | Create a card | +| `PUT` | `/api/{board}/cards/{card_id}` | Update a card | +| `DELETE` | `/api/{board}/cards/{card_id}` | Delete a card | +| `POST` | `/api/{board}/cards/{card_id}/move` | Move a card to another column | +| `POST` | `/api/{board}/reload` | Reload a board from disk | + +## Development + +```bash +cargo test +cargo clippy --all-targets --all-features -- -D warnings +cargo fmt --check +``` + diff --git a/TODO-msp.md b/TODO-msp.md index 71114fa..7efc97b 100644 --- a/TODO-msp.md +++ b/TODO-msp.md @@ -22,6 +22,10 @@ Codenames are architectural/masonry terms — fitting for a company named after | Codename | Initiative | Track | Status | +| Codename | Initiative | Track | Status | + +| Codename | Initiative | Track | Status | + | Codename | Initiative | Track | Status | |-------------|-----------------------------------------------|-------|-------------| | Keystone | Customer portal + billing overhaul | IT | In Progress | @@ -42,6 +46,19 @@ Codenames are architectural/masonry terms — fitting for a company named after + + + + + + + + + + + + + @@ -57,17 +74,17 @@ Codenames are architectural/masonry terms — fitting for a company named after - [ ] `[IT]` **Lintel** — restructure docs.arcline.it into per-tool sections - [ ] `[IT]` **Plinth** — build referral program signup + tracking flow - [ ] `[IT/OS]` **Voussoir** — spec `arcline-migrate` cPanel import mapping -- [ ] `[IT]` — Case studies page — draft first 2 client write-ups - [ ] `[IT]` — Game server plans — add automated backup scheduling to portal +- [ ] `[IT]` — Case studies page — draft first 2 client write-ups ## In Progress -- [ ] `[IT]` **Keystone** — customer portal (`arcline-portal`) SSL + ticketing UI - [ ] `[IT]` **Keystone** — Stripe billing (`arcline-billing`) subscription sync ## In Review - [ ] `[IT]` **Springer** — status page generator (`arcline-status`) — awaiting Blake's sign-off +- [ ] `[IT]` **Keystone** — customer portal (`arcline-portal`) SSL + ticketing UI ## Done diff --git a/__pycache__/build.cpython-314.pyc b/__pycache__/build.cpython-314.pyc deleted file mode 100644 index 5b1f92fea13d1de1bf923e83291101b0b60017ea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 29516 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 dict | None: - """Return in-memory data for *name*, loading from its file if needed.""" - if name not in BOARDS: - return None - if name not in _boards_data: - _boards_data[name] = parse_todo(BOARDS[name]) - return _boards_data[name] - - -def _find_card(board: str, card_id: str) -> tuple[dict, list[dict]] | None: - """Return (card, parent_column_cards_list) or None within *board*.""" - data = _get_board(board) - if data is None: - return None - for col in data["columns"]: - for card in col["cards"]: - if card["id"] == card_id: - return card, col["cards"] - return None - - -def _ensure_board_files() -> None: - """Create any missing board markdown files from TODO.md.""" - original = ROOT / "TODO.md" - if not original.exists(): - return - - for name, path in BOARDS.items(): - if path.exists(): - continue - data = parse_todo(original) - - # Customize title per board - if name == "msp": - data["_header_raw"] = data["_header_raw"].replace( - "# Arcline — TODO", "# Arcline MSP — TODO" - ).replace( - "In-house Kanban board for **Arcline IT** ", - "Kanban board for **Arcline MSP** — IT / managed-services track. ", - ) - # Filter cards: keep IT and IT/OS - for col in data["columns"]: - col["cards"] = [ - c for c in col["cards"] - if "IT" in c.get("track", "") - ] - # Filter legend: keep IT, IT/OS - data["legend"] = [ - r for r in data["legend"] - if "IT" in r.get("track", "") or "OS" not in r.get("track", "") - ] - elif name == "os": - data["_header_raw"] = data["_header_raw"].replace( - "# Arcline — TODO", "# Arcline OS — TODO" - ).replace( - "In-house Kanban board for **Arcline IT** ", - "Kanban board for **Arcline OS** — open-source / GPL track. ", - ) - # Filter cards: keep OS and IT/OS - for col in data["columns"]: - col["cards"] = [ - c for c in col["cards"] - if "OS" in c.get("track", "") - ] - # Filter legend: keep OS, IT/OS - data["legend"] = [ - r for r in data["legend"] - if "OS" in r.get("track", "") or "IT" not in r.get("track", "") - ] - - # Regenerate legend raw from filtered legend list - if data["legend"]: - legend_header = "| Codename | Initiative | Track | Status |" - legend_sep = "|-------------|-----------------------------------------------|-------|-------------|" - legend_rows = [ - f"| {r['codename']:<12} | {r['initiative']:<45} | {r['track']:<5} | {r['status']:<11} |" - for r in data["legend"] - ] - data["_legend_raw"] = "\n".join([legend_header, legend_sep] + legend_rows) - - # Update notes - data["_notes_raw"] = "## Notes\n\n- Board auto-generated from the master TODO.md\n" - - _save_board(data, path) - - -# --------------------------------------------------------------------------- -# Parser (parses a TODO.md file into structured data) -# --------------------------------------------------------------------------- - -def parse_todo(path: Path) -> dict: - """Parse a TODO.md file into a structured dictionary.""" - text = path.read_text() - return { - "header": _parse_header(text), - "legend": _parse_legend(text), - "columns": _parse_columns(text), - "notes": _parse_notes(text), - "_header_raw": _extract_header_raw(text), - "_pre_legend_raw": _extract_pre_legend_raw(text), - "_legend_raw": _extract_legend_raw(text), - "_post_legend_raw": _extract_post_legend_raw(text), - "_notes_raw": _extract_notes_raw(text), - } - - -def _parse_header(text: str) -> dict: - lines = text.splitlines() - title = "" - subtitle = "" - flow_line = "" - - for i, line in enumerate(lines): - stripped = line.strip() - if stripped.startswith("# ") and not title: - title = stripped[2:].strip() - for j in range(i + 1, min(i + 8, len(lines))): - nxt = lines[j].strip() - if nxt.startswith("#") or nxt.startswith("---"): - break - if not nxt: - if subtitle: - break - continue - if not subtitle: - subtitle = nxt - else: - subtitle += " " + nxt - elif "Backlog" in stripped and "In Progress" in stripped: - flow_line = stripped - break - - return {"title": title, "subtitle": subtitle, "flow_line": flow_line} - - -def _parse_legend(text: str) -> list[dict]: - legends: list[dict] = [] - in_table = False - - for line in text.splitlines(): - stripped = line.strip() - if stripped.startswith("| Codename"): - in_table = True - continue - if in_table: - if not stripped.startswith("|"): - break - if "---" in stripped: - continue - cells = [c.strip() for c in stripped.split("|")[1:-1]] - if len(cells) >= 4: - legends.append({ - "codename": cells[0], - "initiative": cells[1], - "track": cells[2], - "status": cells[3], - }) - return legends - - -def _parse_columns(text: str) -> list[dict]: - columns: list[dict] = [] - current_col: dict | None = None - - for line in text.splitlines(): - stripped = line.strip() - - if stripped.startswith("## Backlog"): - current_col = {"name": "Backlog", "cards": []} - columns.append(current_col) - elif stripped.startswith("## In Progress"): - current_col = {"name": "In Progress", "cards": []} - columns.append(current_col) - elif stripped.startswith("## In Review"): - current_col = {"name": "In Review", "cards": []} - columns.append(current_col) - elif stripped.startswith("## Done"): - current_col = {"name": "Done", "cards": []} - columns.append(current_col) - elif stripped.startswith("## ") and current_col and current_col["name"] != "Done": - break - - if current_col and _is_card_line(stripped): - card = _parse_card(stripped) - if card: - current_col["cards"].append(card) - - return columns - - -def _is_card_line(line: str) -> bool: - return line.startswith("- [ ]") or line.startswith("- [x]") - - -def _parse_card(line: str) -> dict | None: - id_match = re.search(r"", line) - card_id = id_match.group(1) if id_match else str(uuid.uuid4()) - clean_line = re.sub(r"", "", line).strip() - - checked = clean_line.startswith("- [x]") - rest = re.sub(r"^- \[[ x]\]\s*", "", clean_line) - - track_match = re.match(r"`\[([^\]]+)\]`", rest) - track = track_match.group(1) if track_match else "" - if track_match: - rest = rest[track_match.end():].strip() - - codename_match = re.match(r"\*\*([^*]+)\*\*", rest) - codename = codename_match.group(1) if codename_match else "" - if codename_match: - rest = rest[codename_match.end():].strip() - - rest = re.sub(r"^—\s*", "", rest) - description = rest.strip() - - return { - "id": card_id, - "track": track, - "codename": codename, - "description": description, - "checked": checked, - } - - -def _parse_notes(text: str) -> list[str]: - notes: list[str] = [] - in_notes = False - - for line in text.splitlines(): - stripped = line.strip() - if stripped.startswith("## Notes"): - in_notes = True - continue - if in_notes: - if stripped.startswith("## "): - break - if stripped.startswith("- "): - notes.append(stripped[2:].strip()) - - return notes - - -# --------------------------------------------------------------------------- -# Raw-section extractors -# --------------------------------------------------------------------------- - -def _extract_header_raw(text: str) -> str: - lines = text.splitlines() - out: list[str] = [] - for line in lines: - if line.strip().startswith("## Codename Legend"): - break - out.append(line) - return "\n".join(out) - - -def _extract_pre_legend_raw(text: str) -> str: - lines = text.splitlines() - out: list[str] = [] - in_section = False - for line in lines: - if line.strip().startswith("## Codename Legend"): - in_section = True - if in_section: - out.append(line) - if line.strip().startswith("|"): - break - return "\n".join(out) - - -def _extract_legend_raw(text: str) -> str: - lines = text.splitlines() - out: list[str] = [] - in_table = False - for line in lines: - stripped = line.strip() - if stripped.startswith("| Codename"): - in_table = True - out.append(line) - continue - if in_table: - if stripped.startswith("|"): - out.append(line) - else: - if stripped == "": - out.append(line) - else: - break - return "\n".join(out) - - -def _extract_post_legend_raw(text: str) -> str: - lines = text.splitlines() - out: list[str] = [] - found_legend_end = False - found_sep = False - for line in lines: - stripped = line.strip() - if found_legend_end and not found_sep: - if stripped == "---": - found_sep = True - out.append(line) - elif stripped == "": - out.append(line) - continue - if found_sep: - if stripped.startswith("## "): - break - out.append(line) - continue - if stripped.startswith("## Codename Legend"): - found_legend_end = False - if stripped.startswith("|"): - found_legend_end = True - return "\n".join(out) - - -def _extract_notes_raw(text: str) -> str: - lines = text.splitlines() - out: list[str] = [] - in_notes = False - for line in lines: - if line.strip().startswith("## Notes"): - in_notes = True - if in_notes: - out.append(line) - return "\n".join(out) - - -# --------------------------------------------------------------------------- -# Serializer -# --------------------------------------------------------------------------- - -def _card_to_md(card: dict) -> str: - checked = "x" if card["checked"] else " " - parts = [f"- [{checked}]"] - if card.get("track"): - parts.append(f"`[{card['track']}]`") - if card.get("codename"): - parts.append(f"**{card['codename']}**") - if card.get("description"): - parts.append(f"— {card['description']}") - line = " ".join(parts) - if card.get("id"): - line += f" " - return line - - -def _save_board(data: dict, path: Path | None = None) -> None: - """Persist board data to its markdown file.""" - if path is None: - return - - sections: list[str] = [] - - sections.append(data["_header_raw"].rstrip()) - sections.append("") - sections.append(data["_pre_legend_raw"].rstrip()) - sections.append("") - sections.append(data["_legend_raw"].rstrip()) - sections.append("") - sections.append(data["_post_legend_raw"].rstrip()) - - for col in data["columns"]: - sections.append("") - sections.append(f"## {col['name']}") - sections.append("") - for card in col["cards"]: - sections.append(_card_to_md(card)) - - sections.append("") - sections.append(data["_notes_raw"].rstrip()) - sections.append("") - - path.write_text("\n".join(sections) + "\n") - - -# --------------------------------------------------------------------------- -# Jinja2 filter -# --------------------------------------------------------------------------- - -@app.template_filter("inline_md") -def _inline_markdown(text: str) -> Markup: - text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text) - text = re.sub(r"\*([^*]+)\*", r"\1", text) - text = re.sub(r"\[([^\]]+)]\(([^)]+)\)", r'\1', text) - return Markup(text) - - -# --------------------------------------------------------------------------- -# Routes — pages -# --------------------------------------------------------------------------- - -@app.route("/") -def home(): - """Board selector.""" - board_list = [ - {"slug": slug, "title": _get_board(slug)["header"]["title"] if _get_board(slug) else slug.upper()} - for slug in BOARDS - ] - return render_template_string( - (TEMPLATES_DIR / "home.html").read_text(), - boards=board_list, - ) - - -@app.route("/") -def board_page(board: str): - """Render a specific Kanban board.""" - data = _get_board(board) - if data is None: - return redirect(url_for("home")) - return render_template_string( - (TEMPLATES_DIR / "board.html").read_text(), - board_slug=board, - **{k: v for k, v in data.items() if not k.startswith("_")}, - ) - - -# --------------------------------------------------------------------------- -# Routes — REST API (per-board) -# --------------------------------------------------------------------------- - -@app.route("/api//board") -def api_board(board: str): - data = _get_board(board) - if data is None: - return jsonify({"ok": False, "error": "Unknown board"}), 404 - return jsonify({ - "header": data["header"], - "legend": data["legend"], - "columns": data["columns"], - "notes": data["notes"], - }) - - -@app.route("/api//cards", methods=["POST"]) -def create_card(board: str): - data = _get_board(board) - if data is None: - return jsonify({"ok": False, "error": "Unknown board"}), 404 - - payload = request.get_json(silent=True) or {} - column_name = payload.get("column", "Backlog") - card = { - "id": str(uuid.uuid4()), - "track": payload.get("track", ""), - "codename": payload.get("codename", ""), - "description": payload.get("description", ""), - "checked": bool(payload.get("checked", False)), - } - - for col in data["columns"]: - if col["name"] == column_name: - col["cards"].append(card) - _save_board(data, BOARDS[board]) - return jsonify({"ok": True, "card": card}), 201 - - return jsonify({"ok": False, "error": f"Unknown column: {column_name}"}), 400 - - -@app.route("/api//cards/", methods=["PUT"]) -def update_card(board: str, card_id: str): - data = _get_board(board) - if data is None: - return jsonify({"ok": False, "error": "Unknown board"}), 404 - - payload = request.get_json(silent=True) or {} - result = _find_card(board, card_id) - if result is None: - return jsonify({"ok": False, "error": "Card not found"}), 404 - - card, _cards_list = result - for field in ("track", "codename", "description", "checked"): - if field in payload: - card[field] = payload[field] - - _save_board(data, BOARDS[board]) - return jsonify({"ok": True, "card": card}) - - -@app.route("/api//cards//move", methods=["POST"]) -def move_card(board: str, card_id: str): - data = _get_board(board) - if data is None: - return jsonify({"ok": False, "error": "Unknown board"}), 404 - - payload = request.get_json(silent=True) or {} - target_column = payload.get("column") - if not target_column: - return jsonify({"ok": False, "error": "Missing 'column' field"}), 400 - - result = _find_card(board, card_id) - if result is None: - return jsonify({"ok": False, "error": "Card not found"}), 404 - - card, source_list = result - target_list = None - for col in data["columns"]: - if col["name"] == target_column: - target_list = col["cards"] - break - - if target_list is None: - return jsonify({"ok": False, "error": f"Unknown column: {target_column}"}), 400 - - source_list.remove(card) - target_list.append(card) - _save_board(data, BOARDS[board]) - return jsonify({"ok": True, "card": card}) - - -@app.route("/api//cards/", methods=["DELETE"]) -def delete_card(board: str, card_id: str): - data = _get_board(board) - if data is None: - return jsonify({"ok": False, "error": "Unknown board"}), 404 - - result = _find_card(board, card_id) - if result is None: - return jsonify({"ok": False, "error": "Card not found"}), 404 - - card, cards_list = result - cards_list.remove(card) - _save_board(data, BOARDS[board]) - return jsonify({"ok": True}) - - -@app.route("/api//reload", methods=["POST"]) -def reload_board(board: str): - if board not in BOARDS: - return jsonify({"ok": False, "error": "Unknown board"}), 404 - _boards_data.pop(board, None) - _get_board(board) - return jsonify({"ok": True}) - - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - -def main() -> None: - _ensure_board_files() - - if "--build" in sys.argv: - env = app.jinja_env - template = env.from_string((TEMPLATES_DIR / "board.html").read_text()) - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - for slug in BOARDS: - data = _get_board(slug) - if data is None: - continue - html = template.render( - board_slug=slug, - **{k: v for k, v in data.items() if not k.startswith("_")}, - ) - (OUTPUT_DIR / f"{slug}.html").write_text(html) - print(f"✅ Rendered {slug} → {OUTPUT_DIR / f'{slug}.html'}") - else: - print("🚀 Serving Kanban boards at http://127.0.0.1:5000") - for slug in BOARDS: - print(f" /{slug}") - app.run(debug=True, host="127.0.0.1", port=5000) - - -if __name__ == "__main__": - main() - diff --git a/output/index.html b/output/index.html deleted file mode 100644 index 10aa67e..0000000 --- a/output/index.html +++ /dev/null @@ -1,789 +0,0 @@ - - - - - -Arcline — TODO - - - - - - - -
-

- - Arcline — TODO -

-

In-house Kanban board for Arcline IT (arcline.it) and The Arcline Project (arclineproject.org).

-
- - - - -
- Track: - IT arcline.it - OS arclineproject.org - IT/OS shared - Move cards between sections as they progress: `Backlog → In Progress → In Review → Done`. -
- - - - - -
-

Codename Legend

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
CodenameInitiativeTrackStatus
KeystoneCustomer portal + billing overhaul - - IT - - - - In Progress -
CornerstoneArcline OS alpha → public ISO release - - OS - - - - In Progress -
Buttress`arcline-audit` hardening & CVE scan integration - - OS - - - - Backlog -
VoussoirMigration tool (cPanel/Plesk → Arcline) - - IT/OS - - - - Backlog -
SpringerStatus page + uptime monitoring rollout - - IT - - - - In Review -
RampartFirewall/nftables default-deny profile pass - - OS - - - - Backlog -
LintelDocs site (docs.arcline.it) restructure - - IT - - - - Backlog -
PlinthReferral program launch - - IT - - - - Backlog -
AbutmentWorkstation (KDE) build target - - OS - - - - Backlog -
-
- - - - - -
- -
-
-

Backlog

- 9 -
-
    - -
  • -
    - - - OS - - - Buttress - -
    -

    wire `arcline-audit` into CI for nightly CVE scans

    -
  • - -
  • -
    - - - OS - - - Rampart - -
    -

    finalize default-deny nftables ruleset, document opt-in exceptions

    -
  • - -
  • -
    - - - OS - - - Abutment - -
    -

    scope KDE Plasma workstation ISO target

    -
  • - -
  • -
    - - - IT - - - Lintel - -
    -

    restructure docs.arcline.it into per-tool sections

    -
  • - -
  • -
    - - - IT - - - Plinth - -
    -

    build referral program signup + tracking flow

    -
  • - -
  • -
    - - - IT/OS - - - Voussoir - -
    -

    spec `arcline-migrate` cPanel import mapping

    -
  • - -
  • -
    - - - IT - - -
    -

    Case studies page — draft first 2 client write-ups

    -
  • - -
  • -
    - - - OS - - -
    -

    Grafana dashboard pack — ship default node_exporter + Loki dashboards

    -
  • - -
  • -
    - - - IT - - -
    -

    Game server plans — add automated backup scheduling to portal

    -
  • - -
-
- -
-
-

In Progress

- 4 -
-
    - -
  • -
    - - - IT - - - Keystone - -
    -

    customer portal (`arcline-portal`) SSL + ticketing UI

    -
  • - -
  • -
    - - - IT - - - Keystone - -
    -

    Stripe billing (`arcline-billing`) subscription sync

    -
  • - -
  • -
    - - - OS - - - Cornerstone - -
    -

    ISO build pipeline (Debian Bookworm base)

    -
  • - -
  • -
    - - - OS - - - Cornerstone - -
    -

    btrfs snapshot rollback QA pass

    -
  • - -
-
- -
-
-

In Review

- 2 -
-
    - -
  • -
    - - - IT - - - Springer - -
    -

    status page generator (`arcline-status`) — awaiting Blake's sign-off

    -
  • - -
  • -
    - - - OS - - -
    -

    `arcline-dns` — multi-resolver propagation checker — code review open

    -
  • - -
-
- -
-
-

Done

- 4 -
-
    - -
  • -
    - - - IT - - -
    -

    Site relaunch — arcline.it copy + pricing page

    -
  • - -
  • -
    - - - OS - - -
    -

    Site launch — arclineproject.org landing page

    -
  • - -
  • -
    - - - OS - - -
    -

    Toolchain announcement — 11 Go tools listed publicly

    -
  • - -
  • -
    - - - IT - - -
    -

    Acquired Saibeo Solutions, onboarded Ed Do as CTO

    -
  • - -
-
- -
- - - - - -
-

Notes

-
    - -
  • Keep `[IT]` (arcline.it, revenue-facing) and `[OS]` (arclineproject.org, GPL/open-source)
  • - -
  • New codenames go in the legend table before the card is created, not after.
  • - -
  • Source repos live on `git.arcline.it` — link commits/PRs in card text once work starts.
  • - -
-
- - - - - - - - - diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..2c01fe6 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,1037 @@ +use std::collections::HashMap; +use std::error::Error; +use std::fs; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock, RwLock}; + +use axum::http::StatusCode; +use axum::response::{Html, IntoResponse, Redirect, Response}; +use axum::routing::{get, post}; +use axum::{extract::Path as AxumPath, extract::State, Json, Router}; +use minijinja::{path_loader, Environment}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value as JsonValue}; +use uuid::Uuid; + +// --------------------------------------------------------------------------- +// Project constants +// --------------------------------------------------------------------------- + +const MASTER_TODO: &str = "TODO.md"; +const BOARD_SPECS: [(&str, &str); 2] = [("msp", "TODO-msp.md"), ("os", "TODO-os.md")]; + +// --------------------------------------------------------------------------- +// Data structures +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize)] +struct Header { + title: String, + subtitle: String, + flow_line: String, +} + +#[derive(Debug, Clone, Serialize)] +struct LegendItem { + codename: String, + initiative: String, + track: String, + status: String, +} + +#[derive(Debug, Clone, Serialize)] +struct Card { + id: String, + track: String, + codename: String, + description: String, + checked: bool, +} + +#[derive(Debug, Clone, Serialize)] +struct Column { + name: String, + cards: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct BoardData { + header: Header, + legend: Vec, + columns: Vec, + notes: Vec, + + #[serde(skip)] + header_raw: String, + #[serde(skip)] + pre_legend_raw: String, + #[serde(skip)] + legend_raw: String, + #[serde(skip)] + post_legend_raw: String, + #[serde(skip)] + notes_raw: String, +} + +// --------------------------------------------------------------------------- +// Regex helpers +// --------------------------------------------------------------------------- + +fn id_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"").unwrap()) +} + +fn checkbox_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"^- \[[ x]\]\s*").unwrap()) +} + +fn track_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"^`\[([^\]]+)\]`").unwrap()) +} + +fn codename_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"^\*\*([^*]+)\*\*").unwrap()) +} + +fn dash_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"^—\s*").unwrap()) +} + +fn bold_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"\*\*([^*]+)\*\*").unwrap()) +} + +fn em_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"\*([^*]+)\*").unwrap()) +} + +fn link_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"\[([^\]]+)]\(([^)]+)\)").unwrap()) +} + +// --------------------------------------------------------------------------- +// Parser +// --------------------------------------------------------------------------- + +fn parse_todo(path: &Path) -> BoardData { + let text = fs::read_to_string(path).unwrap_or_else(|e| { + panic!("unable to read board file {}: {e}", path.display()); + }); + + BoardData { + header: parse_header(&text), + legend: parse_legend(&text), + columns: parse_columns(&text), + notes: parse_notes(&text), + header_raw: extract_header_raw(&text), + pre_legend_raw: extract_pre_legend_raw(&text), + legend_raw: extract_legend_raw(&text), + post_legend_raw: extract_post_legend_raw(&text), + notes_raw: extract_notes_raw(&text), + } +} + +fn parse_header(text: &str) -> Header { + let lines: Vec<&str> = text.lines().collect(); + let mut title = String::new(); + let mut subtitle = String::new(); + let mut flow_line = String::new(); + + for (i, line) in lines.iter().enumerate() { + let stripped = line.trim(); + if stripped.starts_with("# ") && title.is_empty() { + title = stripped[2..].trim().to_string(); + let end = (i + 8).min(lines.len()); + for next in &lines[i + 1..end] { + let s = next.trim(); + if s.starts_with('#') || s.starts_with("---") { + break; + } + if s.is_empty() { + if !subtitle.is_empty() { + break; + } + continue; + } + if subtitle.is_empty() { + subtitle = s.to_string(); + } else { + subtitle.push(' '); + subtitle.push_str(s); + } + } + } else if stripped.contains("Backlog") && stripped.contains("In Progress") { + flow_line = stripped.to_string(); + break; + } + } + + Header { + title, + subtitle, + flow_line, + } +} + +fn parse_legend(text: &str) -> Vec { + let mut items = Vec::new(); + let mut in_table = false; + + for line in text.lines() { + let stripped = line.trim(); + if stripped.starts_with("| Codename") { + in_table = true; + continue; + } + if in_table { + if !stripped.starts_with('|') { + break; + } + if stripped.contains("---") { + continue; + } + + let cells: Vec<&str> = stripped.split('|').collect(); + if cells.len() >= 6 { + items.push(LegendItem { + codename: cells[1].trim().to_string(), + initiative: cells[2].trim().to_string(), + track: cells[3].trim().to_string(), + status: cells[4].trim().to_string(), + }); + } + } + } + + items +} + +fn parse_columns(text: &str) -> Vec { + let mut columns: Vec = Vec::new(); + let mut current_col: Option = None; + + for line in text.lines() { + let stripped = line.trim(); + + if stripped.starts_with("## Backlog") { + columns.push(Column { + name: "Backlog".to_string(), + cards: Vec::new(), + }); + current_col = Some(columns.len() - 1); + } else if stripped.starts_with("## In Progress") { + columns.push(Column { + name: "In Progress".to_string(), + cards: Vec::new(), + }); + current_col = Some(columns.len() - 1); + } else if stripped.starts_with("## In Review") { + columns.push(Column { + name: "In Review".to_string(), + cards: Vec::new(), + }); + current_col = Some(columns.len() - 1); + } else if stripped.starts_with("## Done") { + columns.push(Column { + name: "Done".to_string(), + cards: Vec::new(), + }); + current_col = Some(columns.len() - 1); + } else if stripped.starts_with("## ") + && current_col.is_some_and(|i| columns[i].name != "Done") + { + current_col = None; + } + + if let Some(i) = current_col { + if is_card_line(stripped) { + if let Some(card) = parse_card(stripped) { + columns[i].cards.push(card); + } + } + } + } + + columns +} + +fn is_card_line(line: &str) -> bool { + line.starts_with("- [ ]") || line.starts_with("- [x]") +} + +fn parse_card(line: &str) -> Option { + let id = id_regex() + .captures(line) + .map(|caps| caps[1].to_string()) + .unwrap_or_else(|| Uuid::new_v4().to_string()); + + let clean_line = id_regex().replace_all(line, "").trim().to_string(); + let checked = clean_line.starts_with("- [x]"); + let mut rest = checkbox_regex().replace(&clean_line, "").trim().to_string(); + + let mut track = String::new(); + if let Some(caps) = track_regex().captures(&rest) { + track = caps[1].to_string(); + let end = caps.get(0).unwrap().end(); + rest = rest[end..].trim().to_string(); + } + + let mut codename = String::new(); + if let Some(caps) = codename_regex().captures(&rest) { + codename = caps[1].to_string(); + let end = caps.get(0).unwrap().end(); + rest = rest[end..].trim().to_string(); + } + + rest = dash_regex().replace(&rest, "").trim().to_string(); + let description = rest; + + Some(Card { + id, + track, + codename, + description, + checked, + }) +} + +fn parse_notes(text: &str) -> Vec { + let mut notes = Vec::new(); + let mut in_notes = false; + + for line in text.lines() { + let stripped = line.trim(); + if stripped.starts_with("## Notes") { + in_notes = true; + continue; + } + if in_notes { + if stripped.starts_with("## ") { + break; + } + if let Some(note) = stripped.strip_prefix("- ") { + notes.push(note.trim().to_string()); + } + } + } + + notes +} + +// --------------------------------------------------------------------------- +// Raw-section extractors +// --------------------------------------------------------------------------- + +fn extract_header_raw(text: &str) -> String { + let mut out = Vec::new(); + for line in text.lines() { + if line.trim().starts_with("## Codename Legend") { + break; + } + out.push(line); + } + out.join("\n") +} + +fn extract_pre_legend_raw(text: &str) -> String { + let mut out = Vec::new(); + let mut in_section = false; + + for line in text.lines() { + if line.trim().starts_with("## Codename Legend") { + in_section = true; + } + if in_section { + out.push(line); + if line.trim().starts_with('|') { + break; + } + } + } + + out.join("\n") +} + +fn extract_legend_raw(text: &str) -> String { + let mut out = Vec::new(); + let mut in_table = false; + + for line in text.lines() { + let stripped = line.trim(); + if stripped.starts_with("| Codename") { + in_table = true; + out.push(line); + continue; + } + if in_table { + if stripped.starts_with('|') || stripped.is_empty() { + out.push(line); + } else { + break; + } + } + } + + out.join("\n") +} + +fn extract_post_legend_raw(text: &str) -> String { + let mut out = Vec::new(); + let mut found_legend_end = false; + let mut found_sep = false; + + for line in text.lines() { + let stripped = line.trim(); + + if found_legend_end && !found_sep { + if stripped == "---" { + found_sep = true; + out.push(line); + } else if stripped.is_empty() { + out.push(line); + } + continue; + } + + if found_sep { + if stripped.starts_with("## ") { + break; + } + out.push(line); + continue; + } + + if stripped.starts_with("## Codename Legend") { + found_legend_end = false; + } + if stripped.starts_with('|') { + found_legend_end = true; + } + } + + out.join("\n") +} + +fn extract_notes_raw(text: &str) -> String { + let mut out = Vec::new(); + let mut in_notes = false; + + for line in text.lines() { + if line.trim().starts_with("## Notes") { + in_notes = true; + } + if in_notes { + out.push(line); + } + } + + out.join("\n") +} + +// --------------------------------------------------------------------------- +// Serializer +// --------------------------------------------------------------------------- + +fn card_to_md(card: &Card) -> String { + let checked = if card.checked { "x" } else { " " }; + let mut parts = vec![format!("- [{checked}]")]; + + if !card.track.is_empty() { + parts.push(format!("`[{}]`", card.track)); + } + if !card.codename.is_empty() { + parts.push(format!("**{}**", card.codename)); + } + if !card.description.is_empty() { + parts.push(format!("— {}", card.description)); + } + + let mut line = parts.join(" "); + if !card.id.is_empty() { + line.push_str(&format!(" ", card.id)); + } + line +} + +fn save_board(data: &BoardData, path: &Path) -> Result<(), std::io::Error> { + let mut sections = vec![ + data.header_raw.trim_end().to_string(), + String::new(), + data.pre_legend_raw.trim_end().to_string(), + String::new(), + data.legend_raw.trim_end().to_string(), + String::new(), + data.post_legend_raw.trim_end().to_string(), + ]; + + for col in &data.columns { + sections.push(String::new()); + sections.push(format!("## {}", col.name)); + sections.push(String::new()); + for card in &col.cards { + sections.push(card_to_md(card)); + } + } + + sections.push(String::new()); + sections.push(data.notes_raw.trim_end().to_string()); + sections.push(String::new()); + + fs::write(path, format!("{}\n", sections.join("\n"))) +} + +// --------------------------------------------------------------------------- +// Board-file generation +// --------------------------------------------------------------------------- + +fn ensure_board_files(root: &Path) { + let master = root.join(MASTER_TODO); + if !master.exists() { + return; + } + + let master_data = parse_todo(&master); + + for (slug, filename) in BOARD_SPECS { + let path = root.join(filename); + if path.exists() { + continue; + } + + let mut data = master_data.clone(); + + match slug { + "msp" => { + data.header_raw = data + .header_raw + .replace("# Arcline — TODO", "# Arcline MSP — TODO") + .replace( + "In-house Kanban board for **Arcline IT** ", + "Kanban board for **Arcline MSP** — IT / managed-services track. ", + ); + for col in &mut data.columns { + col.cards.retain(|card| card.track.contains("IT")); + } + data.legend + .retain(|item| item.track.contains("IT") || !item.track.contains("OS")); + } + "os" => { + data.header_raw = data + .header_raw + .replace("# Arcline — TODO", "# Arcline OS — TODO") + .replace( + "In-house Kanban board for **Arcline IT** ", + "Kanban board for **Arcline OS** — open-source / GPL track. ", + ); + for col in &mut data.columns { + col.cards.retain(|card| card.track.contains("OS")); + } + data.legend + .retain(|item| item.track.contains("OS") || !item.track.contains("IT")); + } + _ => {} + } + + data.legend_raw = regenerate_legend_raw(&data.legend); + data.notes_raw = "## Notes\n\n- Board auto-generated from the master TODO.md\n".to_string(); + + if let Err(e) = save_board(&data, &path) { + eprintln!("failed to generate {}: {e}", path.display()); + } + } +} + +fn regenerate_legend_raw(legend: &[LegendItem]) -> String { + if legend.is_empty() { + return String::new(); + } + + let header = + "| Codename | Initiative | Track | Status |"; + let separator = + "|-------------|-----------------------------------------------|-------|-------------|"; + let mut rows = Vec::new(); + for item in legend { + rows.push(format!( + "| {:<12} | {:<45} | {:<5} | {:<11} |", + item.codename, item.initiative, item.track, item.status + )); + } + + [header, separator] + .into_iter() + .chain(rows.iter().map(String::as_str)) + .collect::>() + .join("\n") +} + +// --------------------------------------------------------------------------- +// Minijinja filter +// --------------------------------------------------------------------------- + +fn inline_markdown(value: String) -> minijinja::Value { + let mut out = bold_regex() + .replace_all(&value, "$1") + .to_string(); + out = em_regex().replace_all(&out, "$1").to_string(); + out = link_regex() + .replace_all(&out, "$1") + .to_string(); + minijinja::Value::from_safe_string(out) +} + +// --------------------------------------------------------------------------- +// App state +// --------------------------------------------------------------------------- + +struct BoardStore { + path: PathBuf, + data: Mutex, +} + +#[derive(Clone)] +struct AppState { + root: PathBuf, + env: Arc>, + boards: Arc>>>, + board_order: Arc>, +} + +impl AppState { + fn new(root: PathBuf) -> Self { + ensure_board_files(&root); + + let templates_dir = root.join("templates"); + let mut env = Environment::new(); + env.set_loader(path_loader(&templates_dir)); + env.add_filter("inline_md", inline_markdown); + + let boards = Arc::new(RwLock::new(HashMap::new())); + { + let mut map = boards.write().unwrap(); + for (slug, filename) in BOARD_SPECS { + let path = root.join(filename); + let data = parse_todo(&path); + map.insert( + slug.to_string(), + Arc::new(BoardStore { + path, + data: Mutex::new(data), + }), + ); + } + } + + AppState { + root, + env: Arc::new(env), + boards, + board_order: Arc::new( + BOARD_SPECS + .iter() + .map(|(slug, _)| slug.to_string()) + .collect(), + ), + } + } + + fn get_board(&self, slug: &str) -> Option> { + self.boards.read().unwrap().get(slug).cloned() + } + + fn board_path(&self, slug: &str) -> Option { + BOARD_SPECS + .iter() + .find(|(name, _)| *name == slug) + .map(|(_, filename)| self.root.join(filename)) + } +} + +// --------------------------------------------------------------------------- +// Template rendering helpers +// --------------------------------------------------------------------------- + +fn render_board( + env: &Environment<'static>, + slug: &str, + data: &BoardData, +) -> Result { + let template = env.get_template("board.html")?; + let mut ctx = serde_json::to_value(data).expect("board data should serialize"); + ctx["board_slug"] = json!(slug); + template.render(ctx) +} + +fn render_home( + env: &Environment<'static>, + boards: &[JsonValue], +) -> Result { + let template = env.get_template("home.html")?; + template.render(json!({ "boards": boards })) +} + +// --------------------------------------------------------------------------- +// API helpers +// --------------------------------------------------------------------------- + +type ApiError = (StatusCode, Json); + +fn api_error(status: StatusCode, message: &str) -> ApiError { + (status, Json(json!({ "ok": false, "error": message }))) +} + +fn internal_error(message: String) -> ApiError { + api_error(StatusCode::INTERNAL_SERVER_ERROR, &message) +} + +#[derive(Debug, Deserialize)] +struct CardPayload { + column: Option, + track: Option, + codename: Option, + description: Option, + checked: Option, +} + +#[derive(Debug, Deserialize)] +struct MovePayload { + column: Option, +} + +fn find_card_position(columns: &[Column], card_id: &str) -> Option<(usize, usize)> { + for (col_idx, col) in columns.iter().enumerate() { + if let Some(card_idx) = col.cards.iter().position(|card| card.id == card_id) { + return Some((col_idx, card_idx)); + } + } + None +} + +// --------------------------------------------------------------------------- +// Routes: pages +// --------------------------------------------------------------------------- + +async fn home(State(state): State) -> Result, (StatusCode, &'static str)> { + let mut boards = Vec::new(); + + for slug in state.board_order.iter() { + let title = state + .get_board(slug) + .map(|store| { + let data = store.data.lock().unwrap(); + data.header.title.clone() + }) + .unwrap_or_else(|| slug.to_uppercase()); + + boards.push(json!({ "slug": slug, "title": title })); + } + + let html = render_home(&state.env, &boards) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "template render failed"))?; + Ok(Html(html)) +} + +async fn board_page(State(state): State, AxumPath(board): AxumPath) -> Response { + let Some(store) = state.get_board(&board) else { + return Redirect::to("/").into_response(); + }; + + let data = store.data.lock().unwrap(); + match render_board(&state.env, &board, &data) { + Ok(html) => Html(html).into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + +// --------------------------------------------------------------------------- +// Routes: REST API +// --------------------------------------------------------------------------- + +async fn api_board( + State(state): State, + AxumPath(board): AxumPath, +) -> Result, ApiError> { + let store = state + .get_board(&board) + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "Unknown board"))?; + let data = store.data.lock().unwrap(); + Ok(Json(serde_json::to_value(&*data).unwrap())) +} + +async fn create_card( + State(state): State, + AxumPath(board): AxumPath, + Json(payload): Json, +) -> Result<(StatusCode, Json), ApiError> { + let store = state + .get_board(&board) + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "Unknown board"))?; + + let column_name = payload.column.unwrap_or_else(|| "Backlog".to_string()); + let card = Card { + id: Uuid::new_v4().to_string(), + track: payload.track.unwrap_or_default(), + codename: payload.codename.unwrap_or_default(), + description: payload.description.unwrap_or_default(), + checked: payload.checked.unwrap_or(false), + }; + + let mut data = store.data.lock().unwrap(); + let column = data + .columns + .iter_mut() + .find(|col| col.name == column_name) + .ok_or_else(|| { + api_error( + StatusCode::BAD_REQUEST, + &format!("Unknown column: {column_name}"), + ) + })?; + + column.cards.push(card.clone()); + save_board(&data, &store.path).map_err(|e| internal_error(e.to_string()))?; + + Ok(( + StatusCode::CREATED, + Json(json!({ "ok": true, "card": card })), + )) +} + +async fn update_card( + State(state): State, + AxumPath((board, card_id)): AxumPath<(String, String)>, + Json(payload): Json, +) -> Result, ApiError> { + let store = state + .get_board(&board) + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "Unknown board"))?; + + let mut data = store.data.lock().unwrap(); + let (col_idx, card_idx) = find_card_position(&data.columns, &card_id) + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "Card not found"))?; + + let card = &mut data.columns[col_idx].cards[card_idx]; + if let Some(track) = payload.track { + card.track = track; + } + if let Some(codename) = payload.codename { + card.codename = codename; + } + if let Some(description) = payload.description { + card.description = description; + } + if let Some(checked) = payload.checked { + card.checked = checked; + } + let updated_card = card.clone(); + save_board(&data, &store.path).map_err(|e| internal_error(e.to_string()))?; + + Ok(Json(json!({ "ok": true, "card": updated_card }))) +} + +async fn move_card( + State(state): State, + AxumPath((board, card_id)): AxumPath<(String, String)>, + Json(payload): Json, +) -> Result, ApiError> { + let store = state + .get_board(&board) + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "Unknown board"))?; + + let target_column = payload + .column + .filter(|col| !col.is_empty()) + .ok_or_else(|| api_error(StatusCode::BAD_REQUEST, "Missing 'column' field"))?; + + let mut data = store.data.lock().unwrap(); + let (source_idx, card_idx) = find_card_position(&data.columns, &card_id) + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "Card not found"))?; + let target_idx = data + .columns + .iter() + .position(|col| col.name == target_column) + .ok_or_else(|| { + api_error( + StatusCode::BAD_REQUEST, + &format!("Unknown column: {target_column}"), + ) + })?; + + let card = { + let source_col = &mut data.columns[source_idx]; + source_col.cards.remove(card_idx) + }; + data.columns[target_idx].cards.push(card.clone()); + save_board(&data, &store.path).map_err(|e| internal_error(e.to_string()))?; + + Ok(Json(json!({ "ok": true, "card": card }))) +} + +async fn delete_card( + State(state): State, + AxumPath((board, card_id)): AxumPath<(String, String)>, +) -> Result, ApiError> { + let store = state + .get_board(&board) + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "Unknown board"))?; + + let mut data = store.data.lock().unwrap(); + let (col_idx, card_idx) = find_card_position(&data.columns, &card_id) + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "Card not found"))?; + + data.columns[col_idx].cards.remove(card_idx); + save_board(&data, &store.path).map_err(|e| internal_error(e.to_string()))?; + + Ok(Json(json!({ "ok": true }))) +} + +async fn reload_board( + State(state): State, + AxumPath(board): AxumPath, +) -> Result, ApiError> { + let store = state + .get_board(&board) + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "Unknown board"))?; + let path = state + .board_path(&board) + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "Unknown board"))?; + + let data = parse_todo(&path); + *store.data.lock().unwrap() = data; + + Ok(Json(json!({ "ok": true }))) +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +#[tokio::main] +async fn main() -> Result<(), Box> { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + + let state = AppState::new(root); + let app = Router::new() + .route("/", get(home)) + .route("/{board}", get(board_page)) + .route("/api/{board}/board", get(api_board)) + .route("/api/{board}/cards", post(create_card)) + .route( + "/api/{board}/cards/{card_id}", + axum::routing::put(update_card).delete(delete_card), + ) + .route("/api/{board}/cards/{card_id}/move", post(move_card)) + .route("/api/{board}/reload", post(reload_board)) + .with_state(state); + + let addr = SocketAddr::from(([0, 0, 0, 0], 5000)); + println!("🚀 Serving Kanban boards at http://{addr}"); + for (slug, _) in BOARD_SPECS { + println!(" /{slug}"); + } + + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r#"# Arcline — TODO + +In-house Kanban board for **Arcline IT** ([arcline.it](https://arcline.it)) and +**The Arcline Project** ([arclineproject.org](https://arclineproject.org)). + +Move cards between sections as they progress: `Backlog → In Progress → In Review → Done`. + +--- + +## Codename Legend + +Codenames are architectural/masonry terms — fitting for a company named after an arch. + +| Codename | Initiative | Track | Status | +|-------------|-----------------------------------------------|-------|-------------| +| Keystone | Customer portal + billing overhaul | IT | In Progress | +| Voussoir | Migration tool (cPanel/Plesk → Arcline) | IT/OS | Backlog | + +--- + +## Backlog + +- [ ] `[OS]` **Buttress** — wire scans +- [x] `[IT]` **Keystone** — portal login + +## In Progress + +- [ ] `[OS]` **Cornerstone** — btrfs snapshot QA + +## In Review + +- [ ] `[IT]` **Arch** — review draft + +## Done + +- [x] `[Test]` **Test** — Test + +## Notes + +- Keep tracks tagged. +"#; + + #[test] + fn parses_sample_header() { + let header = parse_header(SAMPLE); + assert_eq!(header.title, "Arcline — TODO"); + assert!(header.subtitle.contains("In-house Kanban board")); + assert!(header.flow_line.contains("Backlog")); + } + + #[test] + fn parses_sample_cards() { + let columns = parse_columns(SAMPLE); + assert_eq!(columns.len(), 4); + + let backlog = &columns[0]; + assert_eq!(backlog.name, "Backlog"); + assert_eq!(backlog.cards.len(), 2); + assert_eq!(backlog.cards[0].id, "11111111-1111-1111-1111-111111111111"); + assert_eq!(backlog.cards[0].track, "OS"); + assert_eq!(backlog.cards[0].codename, "Buttress"); + assert_eq!(backlog.cards[0].description, "wire scans"); + assert!(!backlog.cards[0].checked); + assert!(backlog.cards[1].checked); + + let in_progress = &columns[1]; + assert_eq!(in_progress.cards.len(), 1); + assert_eq!(in_progress.cards[0].track, "OS"); + } + + #[test] + fn round_trips_through_serializer() { + let columns = parse_columns(SAMPLE); + let card = &columns[0].cards[0]; + let line = card_to_md(card); + assert!(line.contains("- [ ]")); + assert!(line.contains("`[OS]`")); + assert!(line.contains("**Buttress**")); + assert!(line.contains("")); + } +} diff --git a/templates/board.html b/templates/board.html index 8b7abbb..ec7c9ff 100644 --- a/templates/board.html +++ b/templates/board.html @@ -585,7 +585,7 @@ {% endif %} - {% set status_lower = item.status.lower().replace(' ', '') %} + {% set status_lower = item.status | lower | replace(' ', '') %} {{ item.status }} @@ -611,7 +611,7 @@
    {% for card in col.cards %} -
  • {% if card.track %} - {% set track_class = 'both' if '/' in card.track else card.track.lower() %} + {% set track_class = 'both' if '/' in card.track else card.track | lower %} {{ card.track }} {% endif %} {% if card.codename %} @@ -659,7 +659,7 @@ @@ -978,7 +978,7 @@ async function handleDrop(e) { if (sourceColumn === targetColumn) return; try { - await api(`/api/cards/${cardId}/move`, { + await api(`/api/{{ board_slug }}/cards/${cardId}/move`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ column: targetColumn })