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 5b1f92f..0000000 Binary files a/__pycache__/build.cpython-314.pyc and /dev/null differ diff --git a/build.py b/build.py deleted file mode 100644 index 580d5d6..0000000 --- a/build.py +++ /dev/null @@ -1,601 +0,0 @@ -#!/usr/bin/env python3 -"""Arcline Kanban — multi-board Kanban with CRUD over markdown files.""" - -from __future__ import annotations - -import re -import sys -import uuid -from pathlib import Path - -from flask import Flask, jsonify, redirect, render_template_string, request, url_for -from markupsafe import Markup - -ROOT = Path(__file__).resolve().parent -TEMPLATES_DIR = ROOT / "templates" -OUTPUT_DIR = ROOT / "output" - -# Board registry — name → file path -BOARDS: dict[str, Path] = { - "msp": ROOT / "TODO-msp.md", - "os": ROOT / "TODO-os.md", -} - -app = Flask(__name__, template_folder=str(TEMPLATES_DIR)) - -# Per-board in-memory state — loaded on first access, synced on write -_boards_data: dict[str, dict] = {} - - -# --------------------------------------------------------------------------- -# Board helpers -# --------------------------------------------------------------------------- - -def _get_board(name: str) -> 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 })