From 25a0f1260fb0ea9395d6c9374dcc51fe0d52bd7e Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Thu, 7 May 2026 11:17:58 +0000 Subject: [PATCH] TUI walking skeleton (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First implementation milestone: Cargo project, dependencies, and a minimal but functional TUI shell built on Ratatui + Crossterm + Tokio in the Elm-style update/view pattern (Candidate A from Phase 2/3 selection). Includes: - Three-region layout: items list (left), output + input + hint (right), bottom status bar with mode-aware shortcuts. - Two themes (light, dark) plus COLORFGBG auto-detect, per NFR-7. CLI: --theme {light,dark}, --log-file . - Input modes per ADR-0003: simple (default), advanced, with the `:` one-shot escape including immediate prompt reaction ("Advanced:" label, advanced border) and auto-inserted space after a leading `:` in simple mode. - App-level commands: `quit`/`q`, `mode simple`/`mode advanced` (canonical list per ADR-0003 — remaining commands land in later iterations). - File logging via tracing, defaulting to ~/.rdbms-playground/ playground.log so the TUI is not corrupted by stdio. Testing per ADR-0008: - Tier 1: 29 unit tests covering input handling, mode switch, one-shot escape, auto-space, output buffering, CLI parsing. - Tier 2: 4 insta snapshots (default simple/advanced/light, one-shot active) of TestBackend frames. - Tier 3: 7 integration tests driving synthetic events through App::update + render path. All green: 36 tests, 0 failures, 0 skips. Clippy clean with nursery lints enabled. --- .gitignore | 12 + Cargo.lock | 2024 +++++++++++++++++ Cargo.toml | 35 + src/action.rs | 12 + src/app.rs | 429 ++++ src/cli.rs | 132 ++ src/event.rs | 15 + src/lib.rs | 16 + src/logging.rs | 74 + src/main.rs | 36 + src/mode.rs | 29 + src/runtime.rs | 140 ++ ...und__ui__tests__default_advanced_dark.snap | 28 + ...round__ui__tests__default_simple_dark.snap | 28 + ...ound__ui__tests__default_simple_light.snap | 28 + ...nd__ui__tests__one_shot_advanced_dark.snap | 28 + src/theme.rs | 69 + src/ui.rs | 303 +++ tests/walking_skeleton.rs | 186 ++ 19 files changed, 3624 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/action.rs create mode 100644 src/app.rs create mode 100644 src/cli.rs create mode 100644 src/event.rs create mode 100644 src/lib.rs create mode 100644 src/logging.rs create mode 100644 src/main.rs create mode 100644 src/mode.rs create mode 100644 src/runtime.rs create mode 100644 src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap create mode 100644 src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap create mode 100644 src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap create mode 100644 src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap create mode 100644 src/theme.rs create mode 100644 src/ui.rs create mode 100644 tests/walking_skeleton.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b15ae8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Build artefacts +/target +**/*.rs.bk + +# Snapshot test review files +*.snap.new +*.pending-snap + +# Editor / OS +.DS_Store +*.swp +*.swo diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4042dff --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2024 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.11.1", + "crossterm_winapi", + "derive_more", + "document-features", + "futures-core", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "insta" +version = "1.47.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" +dependencies = [ + "console", + "once_cell", + "serde", + "similar", + "tempfile", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[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.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[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 = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.11.1", + "compact_str", + "hashbrown 0.16.1", + "indoc", + "itertools", + "kasuari", + "lru", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "rdbms-playground" +version = "0.1.0" +dependencies = [ + "anyhow", + "crossterm", + "futures-util", + "insta", + "pretty_assertions", + "ratatui", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.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 = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[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 = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.11.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "tokio" +version = "1.52.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "atomic", + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[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 = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..99a63e6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "rdbms-playground" +version = "0.1.0" +edition = "2024" +description = "A cross-platform TUI playground for learning relational databases." +license = "MIT OR Apache-2.0" +repository = "https://github.com/sturm/rdbms-playground" +readme = "README.md" +publish = false + +[dependencies] +anyhow = "1.0.102" +crossterm = { version = "0.29.0", features = ["event-stream"] } +futures-util = "0.3.32" +ratatui = "0.30.0" +thiserror = "2.0.18" +tokio = { version = "1.52.2", features = ["full"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } + +[dev-dependencies] +insta = { version = "1.47.2", features = ["yaml"] } +pretty_assertions = "1.4.1" + +[lints.rust] +unsafe_code = "forbid" +unreachable_pub = "warn" + +[lints.clippy] +all = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } +# Allow common false-positives that don't materially improve our code. +module_name_repetitions = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" diff --git a/src/action.rs b/src/action.rs new file mode 100644 index 0000000..ba4d168 --- /dev/null +++ b/src/action.rs @@ -0,0 +1,12 @@ +//! Actions returned by the application's update function. +//! +//! `update` is pure with respect to the runtime: it mutates state +//! in place and returns a list of `Action`s for the runtime to +//! enact (e.g. quit the event loop). Side effects belong here, +//! not in the update logic itself, which keeps `update` directly +//! testable without a Tokio runtime or a real terminal. + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + Quit, +} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..ca2ef8f --- /dev/null +++ b/src/app.rs @@ -0,0 +1,429 @@ +//! Application state and the single `update` entry point. +//! +//! The walking skeleton recognises a small subset of the +//! canonical app-level commands from ADR-0003 — `quit` and +//! `mode` — plus the `:` one-shot escape from simple to advanced +//! per ADR-0003. Everything else is echoed to the output panel +//! tagged with the mode it was submitted under, so that mode +//! handling is visible end-to-end. + +use std::collections::VecDeque; + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use tracing::trace; + +use crate::action::Action; +use crate::event::AppEvent; +use crate::mode::Mode; + +/// Maximum number of output lines kept in the rolling buffer. +const OUTPUT_CAPACITY: usize = 1000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputKind { + Echo, + System, + Error, +} + +/// What mode the next submission would be evaluated in. +/// +/// Derived from the persistent mode and the current input buffer. +/// The UI uses this to give immediate visual feedback for the `:` +/// one-shot escape: the moment a leading `:` is typed in simple +/// mode, the prompt flips to advanced styling, and reverts as +/// soon as the `:` is deleted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EffectiveMode { + Simple, + AdvancedPersistent, + AdvancedOneShot, +} + +impl EffectiveMode { + #[must_use] + pub const fn is_advanced(self) -> bool { + matches!(self, Self::AdvancedPersistent | Self::AdvancedOneShot) + } +} + +#[derive(Debug, Clone)] +pub struct OutputLine { + pub text: String, + pub kind: OutputKind, + pub mode_at_submission: Mode, +} + +#[derive(Debug)] +pub struct App { + pub mode: Mode, + pub input: String, + pub output: VecDeque, + pub hint: Option, +} + +impl Default for App { + fn default() -> Self { + Self::new() + } +} + +impl App { + #[must_use] + pub fn new() -> Self { + Self { + mode: Mode::Simple, + input: String::new(), + output: VecDeque::with_capacity(OUTPUT_CAPACITY), + hint: None, + } + } + + /// Effective mode for the *next* submission, given the + /// persistent mode and the current input buffer. See + /// [`EffectiveMode`]. + #[must_use] + pub fn effective_mode(&self) -> EffectiveMode { + match self.mode { + Mode::Advanced => EffectiveMode::AdvancedPersistent, + Mode::Simple if self.input.trim_start().starts_with(':') => { + EffectiveMode::AdvancedOneShot + } + Mode::Simple => EffectiveMode::Simple, + } + } + + /// Process one event from the runtime, mutating state and + /// returning any actions for the runtime to enact. + pub fn update(&mut self, event: AppEvent) -> Vec { + match event { + AppEvent::Key(key) => self.handle_key(key), + AppEvent::Resize { .. } | AppEvent::Tick => Vec::new(), + } + } + + fn handle_key(&mut self, key: KeyEvent) -> Vec { + // On Windows, key events fire for both Press and Release; only + // honour Press to avoid double-handling. Other platforms only + // emit Press, so this is a no-op there. + if key.kind != KeyEventKind::Press { + return Vec::new(); + } + trace!(?key, "handle_key"); + match (key.code, key.modifiers) { + (KeyCode::Char('c'), KeyModifiers::CONTROL) => vec![Action::Quit], + (KeyCode::Enter, _) => self.submit(), + (KeyCode::Backspace, _) => { + self.input.pop(); + Vec::new() + } + (KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => { + let was_empty = self.input.is_empty(); + self.input.push(c); + // Convenience: when `:` becomes the leading character in + // simple mode, auto-insert a space after it so the input + // reads ": foo" rather than ":foo". The trailing space is + // an ordinary character — backspace removes it normally. + if c == ':' && was_empty && self.mode == Mode::Simple { + self.input.push(' '); + } + Vec::new() + } + _ => Vec::new(), + } + } + + fn submit(&mut self) -> Vec { + let raw = std::mem::take(&mut self.input); + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Vec::new(); + } + + // `:` one-shot escape: in simple mode, a leading `:` means + // treat *this single submission* as advanced. The persistent + // mode is unchanged. + let (effective_mode, effective_input) = + if self.mode == Mode::Simple && trimmed.starts_with(':') { + (Mode::Advanced, trimmed[1..].trim().to_string()) + } else { + (self.mode, trimmed.to_string()) + }; + + if effective_input.is_empty() { + return Vec::new(); + } + + // Canonical app-level commands recognised in both modes. + // The walking skeleton implements only `quit` and `mode`; + // the rest of the canonical list lands in later iterations. + match effective_input.as_str() { + "quit" | "q" => return vec![Action::Quit], + other if other.starts_with("mode") => { + self.handle_mode_command(other); + return Vec::new(); + } + _ => {} + } + + // Default: echo the line tagged with its effective mode. + self.push_output(OutputLine { + text: effective_input, + kind: OutputKind::Echo, + mode_at_submission: effective_mode, + }); + Vec::new() + } + + fn handle_mode_command(&mut self, raw: &str) { + let arg = raw.strip_prefix("mode").unwrap_or(raw).trim(); + match arg { + "simple" => { + self.mode = Mode::Simple; + self.note_system("mode: simple"); + } + "advanced" => { + self.mode = Mode::Advanced; + self.note_system("mode: advanced"); + } + "" => self.note_error("usage: mode simple | mode advanced"), + other => self.note_error(format!( + "unknown mode '{other}' (expected 'simple' or 'advanced')" + )), + } + } + + fn note_system(&mut self, text: impl Into) { + self.push_output(OutputLine { + text: text.into(), + kind: OutputKind::System, + mode_at_submission: self.mode, + }); + } + + fn note_error(&mut self, text: impl Into) { + self.push_output(OutputLine { + text: text.into(), + kind: OutputKind::Error, + mode_at_submission: self.mode, + }); + } + + fn push_output(&mut self, line: OutputLine) { + self.output.push_back(line); + while self.output.len() > OUTPUT_CAPACITY { + self.output.pop_front(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use pretty_assertions::assert_eq; + + fn key(code: KeyCode) -> AppEvent { + AppEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)) + } + + fn key_mod(code: KeyCode, mods: KeyModifiers) -> AppEvent { + AppEvent::Key(KeyEvent::new(code, mods)) + } + + fn type_str(app: &mut App, s: &str) { + for c in s.chars() { + app.update(key(KeyCode::Char(c))); + } + } + + fn submit(app: &mut App) -> Vec { + app.update(key(KeyCode::Enter)) + } + + #[test] + fn typing_accumulates_in_input_buffer() { + let mut app = App::new(); + type_str(&mut app, "hello"); + assert_eq!(app.input, "hello"); + assert!(app.output.is_empty()); + } + + #[test] + fn backspace_removes_last_char() { + let mut app = App::new(); + type_str(&mut app, "abc"); + app.update(key(KeyCode::Backspace)); + assert_eq!(app.input, "ab"); + } + + #[test] + fn enter_in_simple_mode_echoes_with_simple_tag() { + let mut app = App::new(); + type_str(&mut app, "create table foo"); + let actions = submit(&mut app); + assert!(actions.is_empty()); + assert_eq!(app.input, ""); + assert_eq!(app.output.len(), 1); + let line = &app.output[0]; + assert_eq!(line.text, "create table foo"); + assert_eq!(line.kind, OutputKind::Echo); + assert_eq!(line.mode_at_submission, Mode::Simple); + } + + #[test] + fn enter_in_advanced_mode_echoes_with_advanced_tag() { + let mut app = App::new(); + app.mode = Mode::Advanced; + type_str(&mut app, "select 1"); + submit(&mut app); + let line = &app.output[0]; + assert_eq!(line.mode_at_submission, Mode::Advanced); + } + + #[test] + fn mode_command_switches_persistently() { + let mut app = App::new(); + type_str(&mut app, "mode advanced"); + submit(&mut app); + assert_eq!(app.mode, Mode::Advanced); + type_str(&mut app, "mode simple"); + submit(&mut app); + assert_eq!(app.mode, Mode::Simple); + } + + #[test] + fn mode_command_with_unknown_arg_errors() { + let mut app = App::new(); + type_str(&mut app, "mode sideways"); + submit(&mut app); + assert_eq!(app.mode, Mode::Simple); + let last = app.output.back().unwrap(); + assert_eq!(last.kind, OutputKind::Error); + assert!(last.text.contains("unknown mode")); + } + + #[test] + fn colon_prefix_in_simple_mode_is_one_shot_advanced() { + let mut app = App::new(); + type_str(&mut app, ":select 1"); + submit(&mut app); + // The persistent mode is unchanged. + assert_eq!(app.mode, Mode::Simple); + let line = &app.output[0]; + // The submitted line was tagged advanced for this submission. + assert_eq!(line.mode_at_submission, Mode::Advanced); + // The leading `:` is stripped before echoing. + assert_eq!(line.text, "select 1"); + // Subsequent submissions revert to simple. + type_str(&mut app, "list tables"); + submit(&mut app); + assert_eq!(app.output[1].mode_at_submission, Mode::Simple); + } + + #[test] + fn quit_command_returns_quit_action() { + let mut app = App::new(); + type_str(&mut app, "quit"); + let actions = submit(&mut app); + assert_eq!(actions, vec![Action::Quit]); + } + + #[test] + fn ctrl_c_returns_quit_action() { + let mut app = App::new(); + let actions = app.update(key_mod(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert_eq!(actions, vec![Action::Quit]); + } + + #[test] + fn empty_submission_is_a_noop() { + let mut app = App::new(); + let actions = submit(&mut app); + assert!(actions.is_empty()); + assert!(app.output.is_empty()); + } + + #[test] + fn effective_mode_reflects_persistent_mode_when_no_input() { + let mut app = App::new(); + assert_eq!(app.effective_mode(), EffectiveMode::Simple); + app.mode = Mode::Advanced; + assert_eq!(app.effective_mode(), EffectiveMode::AdvancedPersistent); + } + + #[test] + fn effective_mode_flips_to_one_shot_when_colon_typed_in_simple_mode() { + let mut app = App::new(); + type_str(&mut app, ":sel"); + assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot); + // Backspace through the colon reverts. The auto-inserted space + // after the colon counts as one extra character to clear. + while !app.input.is_empty() { + app.update(key(KeyCode::Backspace)); + } + assert_eq!(app.effective_mode(), EffectiveMode::Simple); + } + + #[test] + fn typing_colon_first_in_simple_mode_auto_inserts_a_space() { + let mut app = App::new(); + type_str(&mut app, ":"); + assert_eq!(app.input, ": "); + assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot); + } + + #[test] + fn typing_colon_after_other_chars_does_not_auto_insert_space() { + let mut app = App::new(); + type_str(&mut app, "ab:"); + assert_eq!(app.input, "ab:"); + } + + #[test] + fn typing_colon_in_advanced_mode_does_not_auto_insert_space() { + let mut app = App::new(); + app.mode = Mode::Advanced; + type_str(&mut app, ":"); + assert_eq!(app.input, ":"); + } + + #[test] + fn auto_inserted_space_can_be_removed_with_backspace() { + let mut app = App::new(); + type_str(&mut app, ":"); + assert_eq!(app.input, ": "); + app.update(key(KeyCode::Backspace)); + assert_eq!(app.input, ":"); + // The colon alone still triggers the one-shot. + assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot); + } + + #[test] + fn effective_mode_in_advanced_mode_ignores_leading_colon() { + let mut app = App::new(); + app.mode = Mode::Advanced; + type_str(&mut app, ":hello"); + // Leading `:` carries no special meaning in advanced mode. + assert_eq!(app.effective_mode(), EffectiveMode::AdvancedPersistent); + } + + #[test] + fn effective_mode_tolerates_leading_whitespace_before_colon() { + let mut app = App::new(); + type_str(&mut app, " :select 1"); + assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot); + } + + #[test] + fn output_buffer_is_capped() { + let mut app = App::new(); + for i in 0..(OUTPUT_CAPACITY + 50) { + type_str(&mut app, &format!("line{i}")); + submit(&mut app); + } + assert_eq!(app.output.len(), OUTPUT_CAPACITY); + // Oldest entries were dropped. + assert!(app.output.front().unwrap().text.starts_with("line50")); + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..451a8d1 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,132 @@ +//! CLI argument parsing. +//! +//! Walking-skeleton scope is small enough that a hand-rolled +//! parser is simpler than pulling in clap. When the CLI grows +//! (project loading per L1, L2 etc.) we will revisit. + +use std::env; +use std::path::PathBuf; + +use crate::theme::Theme; + +#[derive(Debug, Clone)] +pub struct Args { + pub theme: Theme, + pub log_path: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum ArgsError { + #[error("missing value for --{0}")] + MissingValue(&'static str), + #[error("invalid value for --{flag}: {value} (expected one of: {expected})")] + InvalidValue { + flag: &'static str, + value: String, + expected: &'static str, + }, + #[error("unknown argument: {0}")] + Unknown(String), +} + +impl Args { + /// Parse `Args` from the process command line. + pub fn from_env() -> Result { + Self::parse(env::args().skip(1)) + } + + /// Parse `Args` from an arbitrary iterator (used by tests). + pub fn parse(iter: I) -> Result + where + I: IntoIterator, + S: Into, + { + let mut theme = default_theme(); + let mut log_path = env::var_os("RDBMS_PLAYGROUND_LOG_FILE").map(PathBuf::from); + let mut iter = iter.into_iter().map(Into::into); + while let Some(arg) = iter.next() { + match arg.as_str() { + "--theme" => { + let value = iter.next().ok_or(ArgsError::MissingValue("theme"))?; + theme = match value.as_str() { + "light" => Theme::light(), + "dark" => Theme::dark(), + other => { + return Err(ArgsError::InvalidValue { + flag: "theme", + value: other.to_string(), + expected: "light, dark", + }); + } + }; + } + "--log-file" => { + let value = iter.next().ok_or(ArgsError::MissingValue("log-file"))?; + log_path = Some(PathBuf::from(value)); + } + other => return Err(ArgsError::Unknown(other.to_string())), + } + } + Ok(Self { theme, log_path }) + } +} + +fn default_theme() -> Theme { + // NFR-7: support both backgrounds. For the walking skeleton we + // honour an explicit `--theme` flag and the COLORFGBG env var + // (which xterm/Konsole/iTerm export in the form `;`). + // True OSC-11 background querying is a later improvement. + if let Ok(value) = env::var("COLORFGBG") + && let Some(bg) = value.split(';').next_back() + && let Ok(code) = bg.trim().parse::() + { + // Standard convention: 0..=6 and 8 are dark backgrounds, + // 7 and 9..=15 are light. ITerm emits 15 for white-ish. + let is_dark = matches!(code, 0..=6 | 8); + return if is_dark { Theme::dark() } else { Theme::light() }; + } + Theme::default() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::theme::Background; + + #[test] + fn no_args_yields_default_theme() { + let args = Args::parse(std::iter::empty::<&str>()).unwrap(); + // The default depends on environment; we only assert it parsed. + let _ = args.theme; + } + + #[test] + fn theme_flag_light() { + let args = Args::parse(["--theme", "light"]).unwrap(); + assert_eq!(args.theme.background, Background::Light); + } + + #[test] + fn theme_flag_dark() { + let args = Args::parse(["--theme", "dark"]).unwrap(); + assert_eq!(args.theme.background, Background::Dark); + } + + #[test] + fn theme_flag_invalid() { + let err = Args::parse(["--theme", "neon"]).unwrap_err(); + assert!(matches!(err, ArgsError::InvalidValue { flag: "theme", .. })); + } + + #[test] + fn theme_flag_missing_value() { + let err = Args::parse(["--theme"]).unwrap_err(); + assert!(matches!(err, ArgsError::MissingValue("theme"))); + } + + #[test] + fn unknown_flag_errors() { + let err = Args::parse(["--bogus"]).unwrap_err(); + assert!(matches!(err, ArgsError::Unknown(s) if s == "--bogus")); + } +} diff --git a/src/event.rs b/src/event.rs new file mode 100644 index 0000000..569a511 --- /dev/null +++ b/src/event.rs @@ -0,0 +1,15 @@ +//! Events fed into the application's update function. +//! +//! `AppEvent` is the single input type the runtime delivers to +//! `App::update`. Synthetic instances drive Tier 3 integration +//! tests (see ADR-0008), so the type is plain data with no +//! runtime dependency. + +use crossterm::event::KeyEvent; + +#[derive(Debug, Clone)] +pub enum AppEvent { + Key(KeyEvent), + Resize { cols: u16, rows: u16 }, + Tick, +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ec5f357 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,16 @@ +//! Library crate for RDBMS Playground. +//! +//! Most of the application lives here so that integration tests in +//! `tests/` and unit tests inside the modules can drive the same +//! code that `main.rs` runs in production. The binary entry point +//! is intentionally thin. + +pub mod action; +pub mod app; +pub mod cli; +pub mod event; +pub mod logging; +pub mod mode; +pub mod runtime; +pub mod theme; +pub mod ui; diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..ad00703 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,74 @@ +//! Tracing-based logging setup. +//! +//! TUI applications cannot write logs to stdout/stderr without +//! corrupting the terminal, so logs go to a file. The path comes +//! from the CLI (`--log-file`) or the `RDBMS_PLAYGROUND_LOG_FILE` +//! environment variable; if neither is set we default to +//! `~/.rdbms-playground/playground.log` and create directories as +//! needed. + +use std::fs::{File, OpenOptions, create_dir_all}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt}; + +const DEFAULT_LOG_DIR: &str = ".rdbms-playground"; +const DEFAULT_LOG_FILE: &str = "playground.log"; + +/// Initialise tracing to write to the given file path, or to a +/// platform-default path when `path` is `None`. +pub fn init(path: Option<&Path>) -> Result { + let chosen = match path { + Some(p) => p.to_path_buf(), + None => default_log_path()?, + }; + if let Some(parent) = chosen.parent() { + create_dir_all(parent) + .with_context(|| format!("create log directory {}", parent.display()))?; + } + let file = open_log_file(&chosen)?; + let filter = EnvFilter::try_from_env("RDBMS_PLAYGROUND_LOG") + .unwrap_or_else(|_| EnvFilter::new("info")); + let layer = fmt::layer() + .with_writer(file) + .with_ansi(false) + .with_target(true); + tracing_subscriber::registry() + .with(filter) + .with(layer) + .init(); + tracing::info!(path = %chosen.display(), "logging initialised"); + Ok(chosen) +} + +fn open_log_file(path: &Path) -> Result { + OpenOptions::new() + .create(true) + .append(true) + .open(path) + .with_context(|| format!("open log file {}", path.display())) +} + +fn default_log_path() -> Result { + let home = home_dir().context("could not determine HOME directory for default log path")?; + Ok(home.join(DEFAULT_LOG_DIR).join(DEFAULT_LOG_FILE)) +} + +fn home_dir() -> Option { + // std::env::home_dir is deprecated; do the lookup ourselves with + // the platform-conventional environment variables. Once we add a + // proper path strategy (project storage, ADR-0004) this can be + // replaced with the chosen helper crate. + if let Some(p) = std::env::var_os("HOME") { + return Some(PathBuf::from(p)); + } + if let (Some(drive), Some(path)) = + (std::env::var_os("HOMEDRIVE"), std::env::var_os("HOMEPATH")) + { + let mut combined = PathBuf::from(drive); + combined.push(path); + return Some(combined); + } + std::env::var_os("USERPROFILE").map(PathBuf::from) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..9619e9e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,36 @@ +use std::process::ExitCode; + +use rdbms_playground::cli::Args; +use rdbms_playground::{logging, runtime}; + +fn main() -> ExitCode { + let args = match Args::from_env() { + Ok(args) => args, + Err(e) => { + eprintln!("rdbms-playground: {e}"); + return ExitCode::from(2); + } + }; + + if let Err(e) = logging::init(args.log_path.as_deref()) { + eprintln!("rdbms-playground: failed to initialise logging: {e:#}"); + return ExitCode::FAILURE; + } + + let tokio_rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + tracing::error!(error = %e, "failed to start tokio runtime"); + return ExitCode::FAILURE; + } + }; + + match tokio_rt.block_on(runtime::run(args.theme)) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + tracing::error!(error = %e, "runtime exited with error"); + eprintln!("rdbms-playground: {e:#}"); + ExitCode::FAILURE + } + } +} diff --git a/src/mode.rs b/src/mode.rs new file mode 100644 index 0000000..0d519d5 --- /dev/null +++ b/src/mode.rs @@ -0,0 +1,29 @@ +//! Input mode for the command field. +//! +//! See ADR-0003 for the design. The two modes determine how the +//! input field interprets a submitted line. The `:` one-shot +//! escape from simple to advanced is handled at submission time +//! in `app::App::submit`, not as additional state here. + +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Mode { + Simple, + Advanced, +} + +impl Mode { + pub const fn label(self) -> &'static str { + match self { + Self::Simple => "SIMPLE", + Self::Advanced => "ADVANCED", + } + } +} + +impl fmt::Display for Mode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.label()) + } +} diff --git a/src/runtime.rs b/src/runtime.rs new file mode 100644 index 0000000..3df4741 --- /dev/null +++ b/src/runtime.rs @@ -0,0 +1,140 @@ +//! Tokio-based event loop. +//! +//! A blocking task reads crossterm events and forwards them onto +//! an `mpsc` channel as `AppEvent`s. The main loop awaits events, +//! feeds them to `App::update`, enacts any returned `Action`s, +//! and redraws the terminal. Future async work (query execution, +//! snapshotting, auto-save) joins the same channel as additional +//! producers, which is why we set the architecture up this way +//! from day one. + +use std::io; +use std::time::Duration; + +use anyhow::{Context, Result}; +use crossterm::event::{ + DisableMouseCapture, EnableMouseCapture, Event as CtEvent, EventStream, +}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use futures_util::StreamExt; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use tokio::sync::mpsc; +use tracing::{debug, error, info, warn}; + +use crate::action::Action; +use crate::app::App; +use crate::event::AppEvent; +use crate::theme::Theme; +use crate::ui; + +const EVENT_CHANNEL_CAPACITY: usize = 64; +const SHUTDOWN_GRACE: Duration = Duration::from_millis(100); + +/// Run the application until a `Quit` action is enacted or the +/// terminal closes. +pub async fn run(theme: Theme) -> Result<()> { + let mut terminal = setup_terminal().context("setup terminal")?; + let result = run_loop(&mut terminal, theme).await; + if let Err(e) = teardown_terminal(&mut terminal) { + // Teardown failures should not mask the primary error. + warn!(error = %e, "terminal teardown failed"); + } + result +} + +async fn run_loop( + terminal: &mut Terminal>, + theme: Theme, +) -> Result<()> { + let (event_tx, mut event_rx) = mpsc::channel::(EVENT_CHANNEL_CAPACITY); + let reader_handle = spawn_event_reader(event_tx); + + let mut app = App::new(); + + // Initial draw before any events arrive. + terminal + .draw(|f| ui::render(&app, &theme, f)) + .context("initial draw")?; + + info!("entering main event loop"); + while let Some(event) = event_rx.recv().await { + let actions = app.update(event); + let mut should_quit = false; + for action in actions { + match action { + Action::Quit => { + debug!("quit action received"); + should_quit = true; + } + } + } + terminal + .draw(|f| ui::render(&app, &theme, f)) + .context("redraw")?; + if should_quit { + break; + } + } + + // Give the reader a moment to notice the dropped sender. + let _ = tokio::time::timeout(SHUTDOWN_GRACE, reader_handle).await; + + info!("event loop exited"); + Ok(()) +} + +fn spawn_event_reader(tx: mpsc::Sender) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut stream = EventStream::new(); + while let Some(maybe_event) = stream.next().await { + match maybe_event { + Ok(CtEvent::Key(key)) => { + if tx.send(AppEvent::Key(key)).await.is_err() { + break; + } + } + Ok(CtEvent::Resize(cols, rows)) => { + if tx.send(AppEvent::Resize { cols, rows }).await.is_err() { + break; + } + } + Ok(_) => { + // Ignore other event kinds (paste, focus, mouse) for now. + } + Err(e) => { + error!(error = %e, "crossterm event stream error"); + break; + } + } + } + debug!("event reader exiting"); + }) +} + +fn setup_terminal() -> Result>> { + enable_raw_mode().context("enable raw mode")?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture) + .context("enter alternate screen")?; + let backend = CrosstermBackend::new(stdout); + let terminal = Terminal::new(backend).context("construct terminal")?; + Ok(terminal) +} + +fn teardown_terminal( + terminal: &mut Terminal>, +) -> Result<()> { + disable_raw_mode().context("disable raw mode")?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + ) + .context("leave alternate screen")?; + terminal.show_cursor().context("show cursor")?; + Ok(()) +} diff --git a/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap b/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap new file mode 100644 index 0000000..3e19b32 --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap @@ -0,0 +1,28 @@ +--- +source: src/ui.rs +expression: snapshot +--- +╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ +│(none yet) ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ │╰──────────────────────────────────────────────────╯ +│ │╭ ADVANCED ────────────────────────────────────────╮ +│ ││ │ +│ │╰──────────────────────────────────────────────────╯ +│ │╭ Hint ────────────────────────────────────────────╮ +│ ││(no active hint) │ +╰──────────────────────────╯╰──────────────────────────────────────────────────╯ +Enter submit · mode simple switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap b/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap new file mode 100644 index 0000000..1c21d39 --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap @@ -0,0 +1,28 @@ +--- +source: src/ui.rs +expression: snapshot +--- +╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ +│(none yet) ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ │╰──────────────────────────────────────────────────╯ +│ │╭ SIMPLE ──────────────────────────────────────────╮ +│ ││ │ +│ │╰──────────────────────────────────────────────────╯ +│ │╭ Hint ────────────────────────────────────────────╮ +│ ││(no active hint) │ +╰──────────────────────────╯╰──────────────────────────────────────────────────╯ +Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap b/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap new file mode 100644 index 0000000..1c21d39 --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap @@ -0,0 +1,28 @@ +--- +source: src/ui.rs +expression: snapshot +--- +╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ +│(none yet) ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ │╰──────────────────────────────────────────────────╯ +│ │╭ SIMPLE ──────────────────────────────────────────╮ +│ ││ │ +│ │╰──────────────────────────────────────────────────╯ +│ │╭ Hint ────────────────────────────────────────────╮ +│ ││(no active hint) │ +╰──────────────────────────╯╰──────────────────────────────────────────────────╯ +Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap b/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap new file mode 100644 index 0000000..53ae533 --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap @@ -0,0 +1,28 @@ +--- +source: src/ui.rs +expression: snapshot +--- +╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ +│(none yet) ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ │╰──────────────────────────────────────────────────╯ +│ │╭ Advanced: ───────────────────────────────────────╮ +│ ││: sel │ +│ │╰──────────────────────────────────────────────────╯ +│ │╭ Hint ────────────────────────────────────────────╮ +│ ││(no active hint) │ +╰──────────────────────────╯╰──────────────────────────────────────────────────╯ +Enter submit · Backspace cancel one-shot · Ctrl-C quit diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..5c3c8e6 --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,69 @@ +//! Theme and colour palette. +//! +//! Two themes are provided — one for light terminal backgrounds +//! and one for dark — per NFR-7. The palette is intentionally +//! small for the walking skeleton; it grows as more views are +//! added. Contrast is chosen against the target background so +//! that foreground text meets WCAG-AA (NFR-5) on both variants. + +use ratatui::style::Color; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Background { + Light, + Dark, +} + +#[derive(Debug, Clone)] +pub struct Theme { + pub background: Background, + pub bg: Color, + pub fg: Color, + pub muted: Color, + pub border: Color, + pub border_advanced: Color, + pub mode_simple: Color, + pub mode_advanced: Color, + pub system: Color, + pub error: Color, +} + +impl Theme { + #[must_use] + pub const fn dark() -> Self { + Self { + background: Background::Dark, + bg: Color::Rgb(0x18, 0x1B, 0x22), + fg: Color::Rgb(0xE6, 0xE6, 0xE6), + muted: Color::Rgb(0x8B, 0x90, 0x9A), + border: Color::Rgb(0x4A, 0x52, 0x65), + border_advanced: Color::Rgb(0xE0, 0x60, 0x60), + mode_simple: Color::Rgb(0x6E, 0xC4, 0xFF), + mode_advanced: Color::Rgb(0xFF, 0x9E, 0x6B), + system: Color::Rgb(0x9F, 0xD8, 0x91), + error: Color::Rgb(0xFF, 0x6B, 0x6B), + } + } + + #[must_use] + pub const fn light() -> Self { + Self { + background: Background::Light, + bg: Color::Rgb(0xFA, 0xFA, 0xF7), + fg: Color::Rgb(0x1A, 0x1F, 0x2C), + muted: Color::Rgb(0x60, 0x66, 0x73), + border: Color::Rgb(0xB6, 0xBC, 0xC8), + border_advanced: Color::Rgb(0xC2, 0x3A, 0x3A), + mode_simple: Color::Rgb(0x21, 0x69, 0xC7), + mode_advanced: Color::Rgb(0xB0, 0x4A, 0x12), + system: Color::Rgb(0x2E, 0x7C, 0x3C), + error: Color::Rgb(0xC0, 0x39, 0x2B), + } + } +} + +impl Default for Theme { + fn default() -> Self { + Self::dark() + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..4885941 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,303 @@ +//! Rendering of the application state into a Ratatui frame. +//! +//! The render function is pure with respect to runtime: given an +//! `App` and a `Theme`, the same frame is produced regardless of +//! when or where it is called. That property is what makes Tier 2 +//! (TestBackend) and Tier 3 (synthetic event) tests in ADR-0008 +//! straightforward. + +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout, Margin, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Wrap}; + +use crate::app::{App, EffectiveMode, OutputKind, OutputLine}; +use crate::mode::Mode; +use crate::theme::Theme; + +/// Render the entire application frame. +pub fn render(app: &App, theme: &Theme, frame: &mut Frame<'_>) { + let area = frame.area(); + paint_background(theme, frame, area); + + // Reserve a single row at the bottom for the shortcut/status bar. + let outer = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(8), Constraint::Length(1)]) + .split(area); + + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(28), Constraint::Min(20)]) + .split(outer[0]); + + render_items_panel(theme, frame, columns[0]); + render_right_column(app, theme, frame, columns[1]); + render_status_bar(app, theme, frame, outer[1]); +} + +fn render_right_column(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(5), // Output panel + Constraint::Length(3), // Input panel + Constraint::Length(3), // Hint panel + ]) + .split(area); + + render_output_panel(app, theme, frame, rows[0]); + render_input_panel(app, theme, frame, rows[1]); + render_hint_panel(app, theme, frame, rows[2]); +} + +fn paint_background(theme: &Theme, frame: &mut Frame<'_>, area: Rect) { + let block = Block::default().style(Style::default().bg(theme.bg).fg(theme.fg)); + frame.render_widget(block, area); +} + +fn render_items_panel(theme: &Theme, frame: &mut Frame<'_>, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme.border)) + .title(Span::styled( + " Tables ", + Style::default() + .fg(theme.fg) + .add_modifier(Modifier::BOLD), + )) + .style(Style::default().bg(theme.bg).fg(theme.fg)); + + let placeholder = Paragraph::new(Line::from(Span::styled( + "(none yet)", + Style::default().fg(theme.muted).add_modifier(Modifier::ITALIC), + ))) + .block(block); + + frame.render_widget(placeholder, area); +} + +fn render_output_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme.border)) + .title(Span::styled( + " Output ", + Style::default() + .fg(theme.fg) + .add_modifier(Modifier::BOLD), + )) + .style(Style::default().bg(theme.bg).fg(theme.fg)); + + let inner = area.inner(Margin { + horizontal: 1, + vertical: 1, + }); + + // Show the most recent lines that fit. The output buffer is + // append-only, so taking from the back gives "most recent". + let visible = inner.height as usize; + let lines: Vec> = app + .output + .iter() + .rev() + .take(visible) + .rev() + .map(|line| render_output_line(line, theme)) + .collect(); + + frame.render_widget(block, area); + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); + frame.render_widget(paragraph, inner); +} + +fn render_output_line<'a>(line: &'a OutputLine, theme: &Theme) -> Line<'a> { + let tag_style = match line.mode_at_submission { + Mode::Simple => Style::default().fg(theme.mode_simple), + Mode::Advanced => Style::default().fg(theme.mode_advanced), + }; + let body_style = match line.kind { + OutputKind::Echo => Style::default().fg(theme.fg), + OutputKind::System => Style::default().fg(theme.system), + OutputKind::Error => Style::default().fg(theme.error), + }; + let tag = match line.kind { + OutputKind::Echo => format!("[{}] ", line.mode_at_submission.label().to_lowercase()), + OutputKind::System => "[system] ".to_string(), + OutputKind::Error => "[error] ".to_string(), + }; + Line::from(vec![ + Span::styled(tag, tag_style), + Span::styled(line.text.as_str(), body_style), + ]) +} + +fn render_input_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { + let effective = app.effective_mode(); + let (border_color, mode_color, label) = match effective { + EffectiveMode::Simple => (theme.border, theme.mode_simple, "SIMPLE"), + EffectiveMode::AdvancedPersistent => { + (theme.border_advanced, theme.mode_advanced, "ADVANCED") + } + // Mixed-case label distinguishes the one-shot (`:`-triggered) + // state from a persistent advanced mode at a glance. + EffectiveMode::AdvancedOneShot => { + (theme.border_advanced, theme.mode_advanced, "Advanced:") + } + }; + + let title = Line::from(vec![ + Span::raw(" "), + Span::styled( + label, + Style::default() + .fg(mode_color) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + ]); + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(border_color)) + .title(title) + .style(Style::default().bg(theme.bg).fg(theme.fg)); + + // Cursor block: the character at the cursor position is rendered + // inverted so it is visible without enabling a real terminal cursor. + let spans = vec![ + Span::styled(app.input.as_str(), Style::default().fg(theme.fg)), + Span::styled(" ", Style::default().add_modifier(Modifier::REVERSED)), + ]; + let paragraph = Paragraph::new(Line::from(spans)).block(block); + frame.render_widget(paragraph, area); +} + +fn render_hint_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme.border)) + .title(Span::styled( + " Hint ", + Style::default() + .fg(theme.fg) + .add_modifier(Modifier::BOLD), + )) + .style(Style::default().bg(theme.bg).fg(theme.fg)); + + let body = app.hint.as_deref().unwrap_or("(no active hint)"); + let paragraph = Paragraph::new(Line::from(Span::styled( + body, + Style::default().fg(theme.muted), + ))) + .block(block) + .wrap(Wrap { trim: false }); + + frame.render_widget(paragraph, area); +} + +fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { + let key_style = Style::default() + .fg(theme.fg) + .add_modifier(Modifier::BOLD); + let sep_style = Style::default().fg(theme.muted); + let label_style = Style::default().fg(theme.muted); + let bar_style = Style::default().bg(theme.bg).fg(theme.muted); + + let separator = Span::styled(" · ", sep_style); + let mut spans: Vec> = Vec::new(); + + let push_shortcut = |spans: &mut Vec>, key: &'static str, label: &'static str| { + if !spans.is_empty() { + spans.push(separator.clone()); + } + spans.push(Span::styled(key, key_style)); + spans.push(Span::raw(" ")); + spans.push(Span::styled(label, label_style)); + }; + + push_shortcut(&mut spans, "Enter", "submit"); + match app.effective_mode() { + EffectiveMode::Simple => { + push_shortcut(&mut spans, ":", "advanced once"); + push_shortcut(&mut spans, "mode advanced", "switch"); + } + EffectiveMode::AdvancedPersistent => { + push_shortcut(&mut spans, "mode simple", "switch"); + } + EffectiveMode::AdvancedOneShot => { + push_shortcut(&mut spans, "Backspace", "cancel one-shot"); + } + } + push_shortcut(&mut spans, "Ctrl-C", "quit"); + + let paragraph = Paragraph::new(Line::from(spans)).style(bar_style); + frame.render_widget(paragraph, area); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::App; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + fn render_to_string(app: &App, theme: &Theme, width: u16, height: u16) -> String { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).expect("create terminal"); + terminal + .draw(|f| render(app, theme, f)) + .expect("draw frame"); + let buffer = terminal.backend().buffer().clone(); + let mut out = String::new(); + for y in 0..buffer.area.height { + for x in 0..buffer.area.width { + out.push_str(buffer[(x, y)].symbol()); + } + out.push('\n'); + } + out + } + + #[test] + fn dark_theme_default_view_snapshot() { + let app = App::new(); + let theme = Theme::dark(); + let snapshot = render_to_string(&app, &theme, 80, 24); + insta::assert_snapshot!("default_simple_dark", snapshot); + } + + #[test] + fn light_theme_default_view_snapshot() { + let app = App::new(); + let theme = Theme::light(); + let snapshot = render_to_string(&app, &theme, 80, 24); + insta::assert_snapshot!("default_simple_light", snapshot); + } + + #[test] + fn advanced_mode_default_view_snapshot() { + let mut app = App::new(); + app.mode = Mode::Advanced; + let theme = Theme::dark(); + let snapshot = render_to_string(&app, &theme, 80, 24); + insta::assert_snapshot!("default_advanced_dark", snapshot); + } + + #[test] + fn one_shot_advanced_prompt_snapshot() { + // Typing `:sel` in simple mode should flip the input panel + // label to `Advanced:` while the persistent mode stays simple. + // The visible input includes the auto-inserted space after `:`. + let mut app = App::new(); + app.input.push_str(": sel"); + let theme = Theme::dark(); + let snapshot = render_to_string(&app, &theme, 80, 24); + insta::assert_snapshot!("one_shot_advanced_dark", snapshot); + } +} diff --git a/tests/walking_skeleton.rs b/tests/walking_skeleton.rs new file mode 100644 index 0000000..45202e9 --- /dev/null +++ b/tests/walking_skeleton.rs @@ -0,0 +1,186 @@ +//! Tier 3 integration tests for the walking skeleton (per ADR-0008). +//! +//! These tests drive synthetic crossterm events through `App::update` +//! and assert on the resulting state and rendered buffer. They +//! exercise the full input → state → render path without a real +//! terminal, so they run on every commit and catch regressions in +//! the wiring between modules. + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::Terminal; +use ratatui::backend::TestBackend; + +use rdbms_playground::action::Action; +use rdbms_playground::app::{App, OutputKind}; +use rdbms_playground::event::AppEvent; +use rdbms_playground::mode::Mode; +use rdbms_playground::theme::Theme; +use rdbms_playground::ui; + +const fn key(code: KeyCode) -> AppEvent { + AppEvent::Key(KeyEvent { + code, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: crossterm::event::KeyEventState::NONE, + }) +} + +fn type_str(app: &mut App, s: &str) -> Vec { + let mut actions = Vec::new(); + for c in s.chars() { + actions.extend(app.update(key(KeyCode::Char(c)))); + } + actions +} + +fn submit(app: &mut App) -> Vec { + app.update(key(KeyCode::Enter)) +} + +fn rendered_text(app: &App, theme: &Theme, width: u16, height: u16) -> String { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).expect("create terminal"); + terminal + .draw(|f| ui::render(app, theme, f)) + .expect("draw frame"); + let buffer = terminal.backend().buffer().clone(); + let mut out = String::new(); + for y in 0..buffer.area.height { + for x in 0..buffer.area.width { + out.push_str(buffer[(x, y)].symbol()); + } + out.push('\n'); + } + out +} + +#[test] +fn typing_then_submitting_produces_an_echo_in_the_output_panel() { + let mut app = App::new(); + let theme = Theme::dark(); + + type_str(&mut app, "hello world"); + let pre_render = rendered_text(&app, &theme, 80, 24); + assert!( + pre_render.contains("hello world"), + "input field should display the typed text:\n{pre_render}" + ); + + let actions = submit(&mut app); + assert!(actions.is_empty()); + assert!(app.input.is_empty(), "input buffer cleared on submit"); + assert_eq!(app.output.len(), 1); + + let post_render = rendered_text(&app, &theme, 80, 24); + assert!( + post_render.contains("hello world"), + "output panel should display the echoed line:\n{post_render}" + ); + assert!( + post_render.contains("[simple]"), + "echo should be tagged with the submission mode:\n{post_render}" + ); +} + +#[test] +fn mode_switch_changes_label_and_subsequent_echoes() { + let mut app = App::new(); + let theme = Theme::dark(); + + let initial = rendered_text(&app, &theme, 80, 24); + assert!(initial.contains("SIMPLE")); + assert!(!initial.contains("ADVANCED")); + + type_str(&mut app, "mode advanced"); + submit(&mut app); + assert_eq!(app.mode, Mode::Advanced); + + let after_switch = rendered_text(&app, &theme, 80, 24); + assert!(after_switch.contains("ADVANCED")); + + type_str(&mut app, "select 1"); + submit(&mut app); + let last = app.output.back().expect("output present"); + assert_eq!(last.mode_at_submission, Mode::Advanced); + assert_eq!(last.kind, OutputKind::Echo); +} + +#[test] +fn colon_escape_in_simple_mode_is_one_shot() { + let mut app = App::new(); + type_str(&mut app, ":select 1"); + submit(&mut app); + assert_eq!(app.mode, Mode::Simple); + assert_eq!(app.output[0].mode_at_submission, Mode::Advanced); + assert_eq!(app.output[0].text, "select 1"); + + type_str(&mut app, "another line"); + submit(&mut app); + assert_eq!(app.output[1].mode_at_submission, Mode::Simple); +} + +#[test] +fn quit_command_returns_quit_action() { + let mut app = App::new(); + type_str(&mut app, "quit"); + let actions = submit(&mut app); + assert_eq!(actions, vec![Action::Quit]); +} + +#[test] +fn rendering_works_at_minimum_useful_size() { + // Sanity check that the layout does not panic at small sizes. + let app = App::new(); + let theme = Theme::dark(); + let _ = rendered_text(&app, &theme, 40, 12); +} + +#[test] +fn typing_colon_in_simple_mode_flips_prompt_to_advanced() { + let mut app = App::new(); + let theme = Theme::dark(); + + // No `:` yet — prompt shows SIMPLE. + type_str(&mut app, "sel"); + let before = rendered_text(&app, &theme, 80, 24); + assert!(before.contains("SIMPLE")); + assert!(!before.contains("Advanced:")); + + // Reset and type `:` first — prompt should flip immediately. + app.input.clear(); + type_str(&mut app, ":"); + let after_colon = rendered_text(&app, &theme, 80, 24); + assert!( + after_colon.contains("Advanced:"), + "input panel should show 'Advanced:' once `:` is typed:\n{after_colon}" + ); + assert!(!after_colon.contains("SIMPLE")); + + // Backspace through both the auto-inserted space and the `:` + // itself reverts the prompt. + while !app.input.is_empty() { + app.update(key(KeyCode::Backspace)); + } + let after_revert = rendered_text(&app, &theme, 80, 24); + assert!(after_revert.contains("SIMPLE")); + assert!(!after_revert.contains("Advanced:")); +} + +#[test] +fn status_bar_lists_quit_and_submit_in_all_modes() { + let mut app = App::new(); + let theme = Theme::dark(); + + let simple = rendered_text(&app, &theme, 80, 24); + assert!(simple.contains("Enter"), "status bar lists Enter"); + assert!(simple.contains("Ctrl-C"), "status bar lists Ctrl-C"); + assert!(simple.contains("mode advanced")); + + type_str(&mut app, "mode advanced"); + submit(&mut app); + let advanced = rendered_text(&app, &theme, 80, 24); + assert!(advanced.contains("Enter")); + assert!(advanced.contains("Ctrl-C")); + assert!(advanced.contains("mode simple")); +}