diff --git a/Cargo.lock b/Cargo.lock index 6ec53c0..ba0f78a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "async-trait" version = "0.1.89" @@ -88,6 +94,21 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.57" @@ -104,6 +125,20 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -137,6 +172,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "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" @@ -147,6 +207,52 @@ dependencies = [ "typenum", ] +[[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", +] + +[[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", +] + +[[package]] +name = "db-app" +version = "0.1.0" +dependencies = [ + "db-config", + "db-core", + "db-drivers", + "rusqlite", + "serde", + "serde_json", +] + [[package]] name = "db-config" version = "0.1.0" @@ -172,10 +278,24 @@ dependencies = [ name = "dbtool-cli" version = "0.1.0" dependencies = [ + "db-app", "db-config", "db-core", "db-drivers", "rusqlite", + "serde", + "serde_json", +] + +[[package]] +name = "dbtool-tui" +version = "0.1.0" +dependencies = [ + "crossterm", + "db-app", + "db-config", + "db-core", + "ratatui", ] [[package]] @@ -211,6 +331,28 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[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 0.61.2", +] + [[package]] name = "fallible-iterator" version = "0.2.0" @@ -328,6 +470,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -340,6 +484,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hmac" version = "0.12.1" @@ -430,6 +580,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -451,6 +607,28 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[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", +] + [[package]] name = "io-enum" version = "1.2.1" @@ -460,6 +638,15 @@ dependencies = [ "derive_utils", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -502,6 +689,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "litemap" version = "0.8.1" @@ -523,6 +716,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + [[package]] name = "lru" version = "0.16.3" @@ -562,8 +764,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -579,7 +782,7 @@ dependencies = [ "flate2", "io-enum", "libc", - "lru", + "lru 0.16.3", "mysql_common", "named_pipe", "pem", @@ -699,6 +902,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pem" version = "3.0.6" @@ -860,6 +1069,27 @@ dependencies = [ "getrandom", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru 0.12.5", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -912,12 +1142,31 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + [[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 = "saturating" version = "0.1.0" @@ -1001,6 +1250,37 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[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 = "simd-adler32" version = "0.3.8" @@ -1026,7 +1306,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1035,6 +1315,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "stringprep" version = "0.1.5" @@ -1046,6 +1332,34 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1130,7 +1444,7 @@ dependencies = [ "mio", "pin-project-lite", "socket2", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1211,6 +1525,35 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-segmentation" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "url" version = "2.5.8" @@ -1380,6 +1723,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1389,6 +1741,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 9635216..5e5ec3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,8 @@ [workspace] members = [ "apps/cli", + "apps/tui", + "crates/db-app", "crates/db-config", "crates/db-core", "crates/db-drivers", @@ -13,6 +15,7 @@ edition = "2021" rust-version = "1.82" [workspace.dependencies] +db-app = { path = "crates/db-app" } db-config = { path = "crates/db-config" } db-core = { path = "crates/db-core" } db-drivers = { path = "crates/db-drivers" } diff --git a/README.md b/README.md index fb26d08..aaf5e22 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,34 @@ - 链接器受限的 Linux 环境:使用 `zig cc` 作为 host linker 执行 `cargo check` / `cargo test` 当前 `connect` / `inspect` / `query` / `export` 在 PostgreSQL、MySQL 与 SQLite 的共享抽象上都已经可用;当前剩余工作主要是 Docker 环境下的跨数据库 smoke 与发布链路。 +同时,仓库现已提供 `crates/db-app` 作为 CLI / TUI 共享应用层,并为各命令新增 `--result-format json` 以输出结构化结果;当前还补充了 `AppOperation`、`OperationState` 与 `AppEvent` 作为 TUI worker/channel 的稳定执行契约,详见 `TUI_BACKEND_CONTRACT.md`。 + +## TUI Shell Baseline + +仓库现已补充 `apps/tui`,用于承载 `dbtool-tui-v1` 的终端工作台骨架,不改变当前 CLI 首发范围。 + +当前 TUI baseline 提供: + +- 独立 `dbtool-tui` 入口 +- 顶部导航、六区主布局和底部快捷键提示 +- 面板焦点管理与基础键盘导航 +- `sqlite-local` 的真实 connect / inspect 路径 +- `sqlite-local` 的真实 query / results / export 工作流 +- `Ready` / `Loading` / `Error` 基础状态框架 + +运行方式: + +```bash +cargo run -p dbtool-tui +``` + +若当前环境缺少 Rust 工具链,可先使用已有二进制: + +```bash +./target/debug/dbtool-tui +``` + +详细范围与验收说明见 `apps/tui/README.md`,共享 UI 方案见 `plans/2026-03-26-dbtool-tui-shell-ui-scope.md`。 ## Workspace 结构 diff --git a/TUI_ACCEPTANCE_CHECKLIST.md b/TUI_ACCEPTANCE_CHECKLIST.md new file mode 100644 index 0000000..8d103bf --- /dev/null +++ b/TUI_ACCEPTANCE_CHECKLIST.md @@ -0,0 +1,80 @@ +# dbtool-tui-v1 验收检查表(阶段 2 分层版) + +## 当前结论 + +- 当前仍不能把 `dbtool-tui-v1` 整体判定为阶段 2 live integration 已全部通过;但 `CMP-36` 的 `sqlite-local` 真实 query / results / export 路径已经完成宿主机侧重建与验收回填。 +- Product / PM 对齐依据:`[CMP-33](/CMP/issues/CMP-33)`、`[CMP-37](/CMP/issues/CMP-37)`、`plans/2026-03-27-dbtool-tui-v1-phase-2-scope.md`。 +- CTO 优先级对齐依据:第二阶段关键路径仍是 `[CMP-34](/CMP/issues/CMP-34)` → `[CMP-35](/CMP/issues/CMP-35)` → `[CMP-36](/CMP/issues/CMP-36)`,QA 不应越过 shared app 契约提前宣告 live 通过。 +- 2026-03-27 当前 runner 已回填 shell baseline 证据:`./target/debug/dbtool-tui` 在 `120x40` 与 `90x20` TTY 路径都可复现,退出码均为 `0`。 +- 当前 runner 不能把“源码可重建”记为通过:系统无 `cargo` / `rustc` / `rustfmt`,无法执行 `cargo check`、`cargo test -p dbtool-tui` 或重新生成二进制。 +- `dbtool-tui` 是 TTY 程序;`./target/debug/dbtool-tui --help` 在无 TTY 场景下返回 `No such device or address`,这必须被记录为非交互限制,而不是被误记为 smoke 失败或帮助能力已通过。 + +## 验收分层 + +### A. Shell baseline(当前可验) + +- [x] 预构建二进制 `target/debug/dbtool-tui` 存在 +- [x] `printf 'q' | script -qec 'stty rows 40 cols 120; ./target/debug/dbtool-tui' /tmp/tui-normal.log` 可稳定启动并退出 +- [x] 正常尺寸下可见六区布局:`Connections`、`Schema Browser`、`Query Editor`、`Results`、`Inspector`、`Status & Activity` +- [x] 正常尺寸下默认状态包含 `Ready`、`Execution Idle`、`Workspace` +- [x] 当前 shell 会展示静态连接 / schema / query / results 上下文,但这些仍是本地示例状态,不是 live shared app 数据 +- [x] `printf 'q' | script -qec 'stty rows 20 cols 90; ./target/debug/dbtool-tui' /tmp/tui-small.log` 可稳定启动并退出 +- [x] 小终端路径会显示 `Terminal Too Small` +- [x] 小终端路径会显示 `Resize the terminal` +- [x] shell baseline 与 live integration 的验收边界已在本文档中显式分层 +- [ ] 当前 runner 上源码重建通过 +- [ ] 当前 runner 上源码测试通过 +- [ ] `dbtool-tui` 独立 release artifact smoke 已建立 + +### B. 第二阶段 live integration(当前必须保持未通过) + +- [ ] `[CMP-34](/CMP/issues/CMP-34)` 已冻结 TUI live integration 契约 +- [ ] `[CMP-35](/CMP/issues/CMP-35)` 的 `sqlite-local` 真实 connect / inspect 路径已完成源码重建并回填运行证据 +- [x] `[CMP-36](/CMP/issues/CMP-36)` 的 `sqlite-local` 真实 query / results / export 路径已完成源码重建并回填运行证据 +- [ ] 真实连接激活可区分 `loading` / `success` / `failure` +- [ ] 真实连接失败后,错误能在 `Inspector` 与 `Status & Activity` 中持续可见 +- [ ] 真实 schema browser 可展示 schema / object / column 层级 +- [ ] empty schema 与 restricted schema 在 live 数据下仍有明确文案 +- [ ] `Query Editor` 可针对真实活动连接执行查询 +- [x] 真实查询可区分 `running` / `success` / `empty` / `error` +- [ ] 真实结果区在常见终端尺寸下仍可浏览 +- [x] export 结果会给出明确成功 / 失败反馈 +- [ ] 全路径复用 `crates/db-app`,不通过 CLI 文本解析旁路取数 + +### C. 非交互 / 发布前路径(当前必须显式保留限制) + +- [x] QA 已确认 `dbtool-tui` 需要 TTY,不能用 `--help` 替代交互 smoke +- [ ] GitHub Actions 已覆盖 `dbtool-tui` smoke +- [ ] 非开发者可按 runbook 从 packaged binary 复现 TUI 验收 +- [ ] TUI 专属 demo / seed / Docker 路径已建立 +- [ ] release artifact / packaged binary 在目标平台有真实执行证据 + +## 当前 blocker + +- [ ] `[CMP-34](/CMP/issues/CMP-34)` 仍在 `in_progress` +- [ ] `[CMP-35](/CMP/issues/CMP-35)` 仍在 `in_progress`,且当前 runner 缺少 Rust 工具链,无法回填重建证据 +- [x] `[CMP-36](/CMP/issues/CMP-36)` 已完成宿主机侧构建与验收回填;当前 agent runner 仍缺 Rust 工具链,但不再阻塞该 issue 关闭 +- [ ] `.github/workflows/release-smoke.yml` 当前只覆盖 `dbtool` +- [ ] 仓库仍无 TUI 专属 demo / seed / Docker live 验证路径 +- [ ] 当前 runner 缺 `cargo` / `rustc` / `rustfmt`,无法执行源码重建、测试与格式化 + +## 2026-03-27 已回填证据 + +- 正常尺寸 shell smoke:`printf 'q' | script -qec 'stty rows 40 cols 120; ./target/debug/dbtool-tui' /tmp/tui-normal.log` +- 正常尺寸 shell 结果:退出码 `0`;日志包含六区布局、`Ready`、`Execution Idle`、静态 connections/schema/results 文案 +- 小终端 shell smoke:`printf 'q' | script -qec 'stty rows 20 cols 90; ./target/debug/dbtool-tui' /tmp/tui-small.log` +- 小终端 shell 结果:退出码 `0`;日志包含 `Terminal Too Small` 与 `Resize the terminal` +- 非交互限制:`./target/debug/dbtool-tui --help` 返回 `Error: Os { code: 6, kind: Uncategorized, message: "No such device or address" }` +- 源码状态盘点:`apps/tui/src/main.rs` 已引入 `sqlite-local` 的真实 connect / inspect / query / results / export worker 接线,以及内联测试覆盖 +- 源码验证限制:当前 runner 无 `cargo` / `rustc`,因此无法在本次回合执行 `cargo check`、`cargo test -p dbtool-tui` 或重新生成 `target/debug/dbtool-tui` + +## 当前不应误判为已通过的能力 + +- [ ] 真实 shared app 连接切换 +- [ ] 真实 inspect / schema browser +- [ ] 真实 query 执行 +- [ ] 真实 empty / error 结果处理 +- [ ] 真实 export 反馈 +- [ ] TUI CI smoke +- [ ] TUI release artifact smoke +- [ ] TUI demo / seed / Docker 路径 diff --git a/TUI_BACKEND_CONTRACT.md b/TUI_BACKEND_CONTRACT.md new file mode 100644 index 0000000..94c29eb --- /dev/null +++ b/TUI_BACKEND_CONTRACT.md @@ -0,0 +1,240 @@ +# TUI Backend Contract + +日期:2026-03-27 +作者:Senior Backend Engineer + +## 1. 当前后端边界 + +- `crates/db-core`:数据库无关的领域对象与基础校验。 +- `crates/db-config`:连接 profile、密码环境变量注入与脱敏摘要。 +- `crates/db-drivers`:PostgreSQL / MySQL / SQLite 驱动实现与差异收敛。 +- `crates/db-app`:`connect` / `inspect` / `query` / `export` 共享应用编排、结构化结果、统一错误对象,以及 TUI worker 可复用的执行状态。 +- `apps/cli`:参数解析、stdout/stderr 渲染、退出码。 + +## 2. 持久化边界 + +- 当前产品仍是单次命令执行模型,没有连接配置持久化、历史记录持久化或后台状态存储。 +- 当前写路径只有: + - `export` 明确指定的输出文件 + - SQLite 目标数据库本身(由用户选择) +- TUI 第二阶段 live integration 仍不引入新的数据库、本地缓存库或后台 daemon 存储契约。 + +## 3. 共享请求对象 + +TUI worker 应直接构造共享 Rust 对象,不要拼接 CLI flags,更不要解析 CLI 文本。 + +### Connect + +- 输入:`db_config::ConnectionProfile` +- 说明: + - `name` 用于当前会话内的目标标识 + - `target` 复用 `db_core::ConnectionTarget` + - `password_env_var` 只作为脱敏标签保留;secret 仍通过环境变量注入,不进入持久化 + +### Inspect + +- 输入:`db_core::InspectRequest` +- 字段: + - `schema: Option` + - `table: Option` +- 约束: + - `table.is_some()` 时必须同时提供 `schema` + +### Query + +- 输入:`db_core::QueryRequest` +- 字段: + - `sql` + - `source` + - `parameters` +- `source` 语义: + - `QuerySource::Inline` + - `QuerySource::File` + +### Export + +- 输入: + - `db_core::QueryRequest` + - `db_core::ExportRequest` +- `ExportRequest` 字段: + - `format` + - `output_path` + - `overwrite` +- 约束: + - 只允许导出返回结果集的查询 + - 不静默覆盖已有文件 + - 不为 TUI 增加新的导出元数据存储 + +## 4. 共享结构化结果 + +TUI 应优先直接调用 `crates/db-app`,不要解析 CLI 的人类可读文本。 + +### Connect + +- 返回 `ConnectResponse` +- 字段: + - `target.profile_name` + - `target.driver` + - `target.endpoint` + - `target.password_env_var` + - `status` +- 当前 `status` 固定为 `connected` + +### Inspect + +- 返回 `InspectResponse` +- 字段: + - `target` + - `scope.schema` + - `scope.table` + - `payload` +- `payload.kind` 为: + - `schemas` + - `tables` + - `columns` +- `payload.items` 允许为空;空数组不是错误 + +### Query + +- 返回 `QueryResponse` +- 字段: + - `target` + - `source.kind`:`inline` / `file` + - `source.label` + - `source.path` + - `result.columns` + - `result.rows` + - `result.rows_affected` + - `result.row_count` +- `rows_affected != null` 表示 command-style 执行成功但没有结果集 + +### Export + +- 返回 `ExportResponse` +- 字段: + - `target` + - `source` + - `export.format` + - `export.output_path` + - `export.overwrite` + - `row_count` + +## 5. 错误模型 + +`db-app::AppError.kind` 当前分为: + +- `validation` +- `connection` +- `authentication` +- `inspect` +- `query` +- `export` + +这组分类是 TUI banner、QA 断言和未来 GUI 状态映射的当前稳定基础。 + +`AppError.target` 为可选字段: + +- 有目标上下文时返回脱敏后的 `ConnectionSummary` +- 参数校验在目标尚未构成时可为 `null` + +## 6. Worker / Channel 状态契约 + +`crates/db-app` 现提供: + +- `AppOperation`:`connect | inspect | query | export` +- `OperationState`:`running | success | empty | error` +- `AppEvent`:worker / channel 推荐 envelope + +推荐发送顺序: + +1. 先发 `AppEvent::running(operation)` +2. 成功后发送 `response.into_event()` +3. 失败时发送 `error.into_event()` + +状态语义固定如下: + +- `connect` + - `running`:正在测试连接 + - `success`:连接成功 + - `error`:连接失败 +- `inspect` + - `success`:返回非空 schema / table / column 集合 + - `empty`:返回空集合,但请求本身成功 + - `error`:inspect 失败 +- `query` + - `success`:返回至少一行,或 `rows_affected` 非空 + - `empty`:返回零行且 `rows_affected == null` + - `error`:执行失败 +- `export` + - `success`:文件写入成功;`row_count == 0` 仍算成功 + - `error`:导出校验、查询或文件写入失败 + +示例: + +```rust +use db_app::{AppEvent, AppOperation}; + +sender.send(AppEvent::<()>::running(AppOperation::Query))?; + +match db_app::query(&profile, &request) { + Ok(response) => sender.send(response.into_event())?, + Err(error) => sender.send(error.into_event())?, +} +``` + +## 7. CLI 结构化输出 + +CLI 继续保留默认文本输出,同时新增: + +- `--result-format text` +- `--result-format json` + +JSON 返回 envelope: + +```json +{ + "status": "success", + "operation": "query", + "data": {} +} +``` + +错误返回 envelope: + +```json +{ + "status": "error", + "operation": "query", + "error": { + "operation": "query", + "kind": "query", + "message": "..." + } +} +``` + +CLI JSON envelope 继续作为跨入口回归样本,但 TUI live path 不应以调用 CLI 二进制作为主集成方式。 + +## 8. Frontend 接入约束 + +- TUI 直接依赖 `crates/db-app` 与 `db-core`,不解析 CLI stdout/stderr +- UI 状态映射应优先使用 `OperationState`,不要自行发明另一套 success/empty/error 语义 +- `ConnectionSummary.endpoint` 与 `password_env_var` 仅用于展示脱敏上下文,不应用作 secret 来源 +- `AppError.kind` 必须原样进入状态区 / inspector,避免把 `validation`、`connection`、`query` 混成统一“失败” +- 若未来引入异步 worker 池,仍以共享 response/error 对象作为唯一 UI 数据源 + +## 9. 当前回归边界 + +- `crates/db-app` 回归需覆盖: + - response -> `OperationState` 映射 + - `AppEvent` / `AppError` 序列化 + - 空 inspect / 空 query / command query 语义 +- `apps/cli` 回归继续覆盖: + - `--result-format json` envelope + - text/json 输出在相同 `db-app` 结果上的一致性 + +## 10. 演进规则 + +- CLI 文本文案可小幅演进,但 `db-app` 结构化对象字段应保持兼容。 +- 新增数据库驱动时,优先扩展 `db-drivers` 与 `db-app`,不要在 TUI/CLI 内复制数据库语义。 +- 若未来引入本地持久化(例如连接历史),应先新增独立文档定义存储边界,再进入实现。 diff --git a/TUI_REGRESSION_CHECKLIST.md b/TUI_REGRESSION_CHECKLIST.md new file mode 100644 index 0000000..20dcdf6 --- /dev/null +++ b/TUI_REGRESSION_CHECKLIST.md @@ -0,0 +1,96 @@ +# dbtool-tui-v1 回归清单(阶段 2 分层版) + +## 0. 记录前提 + +- [ ] 记录验证日期、验证人、issue / heartbeat +- [ ] 记录二进制路径:`target/debug/dbtool-tui`、源码构建产物或 packaged binary +- [ ] 记录是否为“预构建二进制验证”还是“源码重建验证” +- [ ] 记录终端尺寸、输入按键、输出日志路径 +- [ ] 若 live integration 已接线,记录对应 issue、构建版本、demo 环境与 seed 来源 +- [ ] 若任一步骤未执行,记录原因:未实现、缺环境、被上游 blocker 卡住,或非当前范围 + +## 1. Shell baseline 回归(当前 runner 可执行) + +- [ ] `printf 'q' | script -qec 'stty rows 40 cols 120; ./target/debug/dbtool-tui' /tmp/tui-normal.log` +- [ ] 确认退出码为 `0` +- [ ] 确认日志包含 `Connections` +- [ ] 确认日志包含 `Schema Browser` +- [ ] 确认日志包含 `Query Editor` +- [ ] 确认日志包含 `Results` +- [ ] 确认日志包含 `Inspector` +- [ ] 确认日志包含 `Status & Activity` +- [ ] 确认日志包含 `Ready` +- [ ] 确认日志包含 `Execution Idle` +- [ ] 确认日志包含静态连接 / schema / result 示例文本 +- [ ] `printf 'q' | script -qec 'stty rows 20 cols 90; ./target/debug/dbtool-tui' /tmp/tui-small.log` +- [ ] 确认退出码为 `0` +- [ ] 确认日志包含 `Terminal Too Small` +- [ ] 确认日志包含 `Resize the terminal` +- [ ] 明确记录:`dbtool-tui --help` 不是有效的非交互 smoke + +## 2. Shell 交互回归(静态 / 示例数据,不等于 live) + +- [ ] `Tab` / `Shift+Tab`:焦点在六个面板之间移动 +- [ ] `1` / `2` / `3`、`←` / `→`:顶部视图切换 +- [ ] `Connections` 焦点下 `↑` / `↓`:连接选择移动 +- [ ] `Connections` 焦点下 `Enter`:激活所选静态连接并同步工作区上下文 +- [ ] 失败连接激活后仍保持可见错误 +- [ ] `Schema Browser` 焦点下 `↑` / `↓`:schema / object 选择移动 +- [ ] `Schema Browser` 焦点下 `Enter`:ready schema 展开 / 折叠;empty / restricted schema 给出明确反馈 +- [ ] `Query Editor` 焦点下 `[` / `]`:切换静态 query 草稿 +- [ ] `i`:进入插入模式并可录入字符 / 换行 +- [ ] `Enter`:触发静态 query 状态轮转或运行占位逻辑 +- [ ] 结果区可区分 success / empty / error +- [ ] `Results` 焦点下 `[` / `]`:宽表列分页 +- [ ] `Results` 焦点下 `↑` / `↓`:结果行移动 +- [ ] `l` / `e` / `r`:状态切换 +- [ ] `Esc`:恢复默认工作台 +- [ ] `q`:稳定退出并释放 alternate screen + +## 3. 第二阶段 live integration 回归(仅在 `[CMP-34](/CMP/issues/CMP-34)`、`[CMP-35](/CMP/issues/CMP-35)`、`[CMP-36](/CMP/issues/CMP-36)` 就绪后执行) + +- [ ] 真实连接列表可加载 +- [ ] 真实连接激活可区分 loading / success / failure +- [ ] 真实连接失败详情会持续显示在 `Inspector` 与 `Status & Activity` +- [ ] 真实 schema / object / column 可浏览 +- [ ] empty schema 有明确空状态 +- [ ] restricted schema 有明确失败状态 +- [ ] `Query Editor` 可对真实活动连接执行查询 +- [ ] 真实查询会显示 running / success / empty / error +- [ ] 真实 success 结果可浏览 +- [ ] 真实 empty 结果不会误记为错误 +- [ ] 真实 error 结果带可读定位线索 +- [ ] export 操作有成功 / 失败反馈 +- [ ] 工作区上下文在 inspect -> edit -> run -> review -> export 之间保持连续 +- [ ] 全路径确认未解析 CLI 文本 + +## 4. 源码 / CI / 发布前回归 + +- [ ] `cargo build -p dbtool-tui` +- [ ] `cargo test -p dbtool-tui` +- [ ] 内联测试与交互 smoke 都能在同一环境复现 +- [ ] 仓库存在 TUI 专属 smoke runbook +- [ ] GitHub Actions 覆盖 `dbtool-tui` +- [ ] packaged binary 在目标平台可执行 +- [ ] 非开发者按 runbook 可复现最小验收 + +## 2026-03-27 当前已回填项 + +- [x] 预构建二进制正常尺寸 shell smoke +- [x] 正常尺寸退出码 `0` +- [x] 正常尺寸日志含六区布局 +- [x] 正常尺寸日志含 `Ready` +- [x] 正常尺寸日志含 `Execution Idle` +- [x] 小终端 shell smoke +- [x] 小终端退出码 `0` +- [x] 小终端日志含 `Terminal Too Small` +- [x] 小终端日志含 `Resize the terminal` +- [x] 已确认 `dbtool-tui --help` 不能替代 TTY smoke + +## 当前阻塞中的回填项 + +- [ ] 源码重建:当前 runner 缺 `cc` / `zig cc` +- [ ] 源码测试:当前 runner 缺 `cc` / `zig cc` +- [ ] live integration 回归:等待 `[CMP-34](/CMP/issues/CMP-34)`、`[CMP-35](/CMP/issues/CMP-35)`、`[CMP-36](/CMP/issues/CMP-36)` +- [ ] CI smoke:仓库尚未覆盖 `dbtool-tui` +- [ ] release artifact smoke:仓库尚未为 `dbtool-tui` 建立产物路径 diff --git a/TUI_TEST_STRATEGY.md b/TUI_TEST_STRATEGY.md new file mode 100644 index 0000000..f3a0f76 --- /dev/null +++ b/TUI_TEST_STRATEGY.md @@ -0,0 +1,121 @@ +# dbtool-tui-v1 测试策略(阶段 2 分层版) + +## 目标 + +- 把 `dbtool-tui-v1` 的 shell baseline 验收、第二阶段 live integration 验收、以及非交互 / 发布前验证彻底拆层。 +- 让 QA 对每个关键用户流都能给出“已执行通过 / 未执行 / 被 blocker 卡住”的可审计判断。 +- 让 CTO 和 Product Manager 看到真实优先级:先 shared app 契约,再 live 接线,最后才是 live 验收闭环。 + +## 当前资产盘点 + +### 已存在的共享资产 + +- `apps/tui/src/main.rs`:TUI 主实现,当前包含静态连接、schema、query、results 示例状态 +- `apps/tui/README.md`:当前交互范围与产品/体验说明 +- `TUI_ACCEPTANCE_CHECKLIST.md`:阶段 2 分层验收矩阵 +- `TUI_REGRESSION_CHECKLIST.md`:阶段 2 分层回归清单 +- `plans/2026-03-27-dbtool-tui-v1-phase-2-scope.md`:CTO 第二阶段范围与 gating +- `backlog/2026-03-27-dbtool-tui-v1-phase-2-issues.md`:owner-backed issue 分解 + +### 已发现的测试资产 + +- `apps/tui/src/main.rs` 内已有 5 个内联测试,覆盖: + - query 草稿切换 + - editor `Enter` 触发 running + - empty query 完成态 + - results 宽表列分页 + - insert mode 录入 +- 当前存在可执行的预构建二进制:`target/debug/dbtool-tui` + +### 2026-03-27 已执行证据 + +- TTY shell smoke:`./target/debug/dbtool-tui` 在 `120x40` 下可启动、渲染并以 `q` 退出,退出码 `0` +- 小终端降级:同一二进制在 `90x20` 下显示 `Terminal Too Small` 与 `Resize the terminal`,退出码 `0` +- 非交互限制:`./target/debug/dbtool-tui --help` 在当前无 TTY 场景下返回 `No such device or address` + +### 当前缺口 + +- 当前 runner 默认无 `cargo` / `rustc` 在 PATH +- 虽存在离线 Rust toolchain,但源码重建 / 测试仍被系统缺少 `cc` / `zig cc` 阻塞 +- 仓库暂无 TUI 专属 smoke runbook +- `.github/workflows/release-smoke.yml` 当前只覆盖 `dbtool` +- 当前无 TUI 专属 demo / seed / Docker 路径 +- 第二阶段 live integration 仍未整体完成:`[CMP-34](/CMP/issues/CMP-34)` 仍在 `in_progress`,`[CMP-35](/CMP/issues/CMP-35)` 仍待宿主机回填,而 `[CMP-36](/CMP/issues/CMP-36)` 已完成宿主机侧验收 + +## 测试分层 + +### 1. 源码层验证 + +- 目标:确认实现可重建、内联测试可运行 +- 当前命令: + - `cargo build -p dbtool-tui` + - `cargo test -p dbtool-tui` +- 当前判断: + - 这是必要层,但当前 runner 不能给出通过结论 + - 缺 `cc` / `zig cc` 时,应记录为环境 blocker,不能伪造成产品失败,也不能反向伪造成已通过 + +### 2. TTY shell smoke + +- 目标:最低成本确认 TUI 可启动、可渲染、可退出、可在小终端降级 +- 当前建议: + - 使用 `script` + `stty` 固化 TTY 启动路径 + - 用预构建二进制先取证,再视环境补做源码重建后的同路径复核 +- 注意: + - `dbtool-tui` 不是普通 CLI;不要用 `--help` 替代交互 smoke + +### 3. Shell 交互回归(静态 / 示例数据) + +- 目标:验证当前 shell 已实现的交互骨架,但不把示例状态误记为 live 能力 +- 范围: + - 视图切换 + - 焦点切换 + - 静态连接激活 + - 静态 schema / object 浏览 + - 查询草稿切换、插入模式、运行占位、结果分页 + - 错误 / 空状态 / 恢复路径 +- 输出要求: + - 明确标记“这是 shell 示例状态验证,不是 shared app live 数据验证” + +### 4. 第二阶段 live integration 验收 + +- 目标:在真实 shared app 接线落地后,对连接、inspect、query、results、export 给出验收判断 +- 前置条件: + - `[CMP-34](/CMP/issues/CMP-34)` 完成并冻结契约 + - `[CMP-35](/CMP/issues/CMP-35)` 完成真实连接与 schema browser 接线 + - `[CMP-36](/CMP/issues/CMP-36)` 完成真实 query / results / export 接线 +- 必验路径: + - 真实连接 `loading` / `success` / `failure` + - 真实 inspect ready / empty / restricted + - 真实 query running / success / empty / error + - export 成功 / 失败反馈 + - shared app 边界复用,无 CLI 文本解析旁路 + +### 5. 发布前 / 非交互验证 + +- 目标:确认非开发者与 CI 都能重复最小验收 +- 当前判断: + - 尚未建立 + - 若后续仍要求 TTY 启动,应在 runbook 与 workflow 中显式写清,不得假装支持普通非交互 `--help` / `--version` smoke + +## 证据规则 + +- 每个“通过”项都必须附带命令、终端尺寸、输入按键、输出摘要或错误摘要 +- 每个“未执行”项都必须标原因:未实现、缺环境、被上游 issue 卡住、或超出当前范围 +- 代码阅读、README 声明、和真实执行证据必须分开写 +- 不能用 CLI 已通过来替代 TUI 已通过 +- 不能把静态示例状态写成真实数据库能力 +- 不能把 TTY 程序的 `--help` 失败误写成已支持的 smoke 入口 + +## 当前优先级 + +1. 保住 shell baseline 证据,不让第二阶段重构把它冲掉 +2. 等待 `[CMP-34](/CMP/issues/CMP-34)` 冻结 shared app 契约 +3. 在 `[CMP-35](/CMP/issues/CMP-35)` / `[CMP-36](/CMP/issues/CMP-36)` 完成后补 live integration 验收 +4. 最后再补 TUI 专属 CI / release / runbook + +## 当前验收判断 + +- shell baseline:有预构建二进制级别的可重复 smoke,属“有条件通过” +- 源码层验证:当前 runner blocked,不能给通过结论 +- 第二阶段 live integration:当前 blocked,不能给通过结论 +- 发布前状态:未就绪,因为 `dbtool-tui` 仍缺 CI、release artifact smoke 和 live demo 路径 diff --git a/apps/tui/Cargo.toml b/apps/tui/Cargo.toml new file mode 100644 index 0000000..c4b3486 --- /dev/null +++ b/apps/tui/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "dbtool-tui" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +publish = false + +[[bin]] +name = "dbtool-tui" +path = "src/main.rs" + +[dependencies] +crossterm = "0.28.1" +db-app.workspace = true +db-config.workspace = true +db-core.workspace = true +ratatui = "0.29.0" + +[lints] +workspace = true diff --git a/apps/tui/README.md b/apps/tui/README.md new file mode 100644 index 0000000..8f0980d --- /dev/null +++ b/apps/tui/README.md @@ -0,0 +1,114 @@ +# dbtool-tui Shell + +`apps/tui` 提供 `dbtool-tui-v1` 的终端工作台实现。 + +## 当前范围 + +当前实现包含: + +- 独立 `dbtool-tui` 应用入口 +- 稳定的六区工作台布局 +- 顶部视图切换与底部快捷键提示 +- 连接管理列表、当前连接上下文和失败连接可视化区分 +- 键盘驱动的连接切换工作流,以及连接 `loading / success / failure` 可视状态 +- `sqlite-local` 的真实 connect / inspect 路径,schema browser 可展示 live schema / table / column +- `sqlite-local` 的真实 query / results / export 工作流,直接复用 `crates/db-app` +- 查询草稿切换、基础键入编辑、执行触发与执行状态反馈 +- 成功 / 空结果 / 错误三类查询结果展示,以及宽表列分页提示 +- 当前结果集导出到 `/tmp` 的 CSV / JSON 反馈 +- 面板焦点管理 +- 列表选择与基础键盘导航 +- `Ready` / `Loading` / `Error` 三种基础界面状态 +- 终端尺寸过小的显式降级提示 + +当前不包含: + +- 真实网络数据库连接激活 +- 多连接并发会话 +- 自定义新增 / 编辑连接表单 +- 完整 SQL 编辑器能力 + +## 运行方式 + +在项目根目录执行: + +```bash +cargo run -p dbtool-tui +``` + +当前默认 live 路径是 `sqlite-local`,底层使用 `examples/tmp/dbtool-demo.sqlite`。 +启动后会自动对 `sqlite-local` 执行 connect + inspect;切换连接时会重新触发 live activation。 + +如果当前 runner 缺少 Rust 工具链,可先使用已有产物: + +```bash +./target/debug/dbtool-tui +``` + +## 键盘交互 + +- `Tab` / `Shift+Tab`:切换焦点面板 +- `←` / `→` 或 `1` / `2` / `3`:切换顶部视图 +- `↑` / `↓`:移动连接、schema/object 或结果行选择 +- `Enter`:在 `Connections` 焦点下激活所选连接;在 `Schema Browser` 焦点下展开 / 折叠 schema 或确认对象;在 `Query Editor` 焦点下执行当前查询 +- `i`:在 `Query Editor` 中进入基础插入模式 +- `x`:将当前结果集导出为 CSV 到 `/tmp` +- `j`:将当前结果集导出为 JSON 到 `/tmp` +- `[` / `]`:在 `Query Editor` 中切换查询草稿;在 `Results` 中横向翻页宽表列 +- `r`:清空当前 query / export 反馈,回到 `Ready` +- `Esc`:回到默认工作台 +- `q`:退出 + +## 快速测试路径 + +如果你只是想快速走通一遍当前可见工作流,推荐按下面步骤操作: + +1. 启动 `cargo run -p dbtool-tui` +2. 等待默认连接 `sqlite-local` 从 `Loading` 变成可用状态 +3. 按 `Tab` 把焦点切到 `Schema Browser` +4. 用 `↑` / `↓` 选择 `main.accounts` 或 `main.tickets` +5. 按 `Tab` 切到 `Query Editor` +6. 先不要按 `i`,直接按 `[` / `]` 切换草稿 +7. 选中 `account_ticket_summary.sql` 后按 `Enter`,确认成功结果 +8. 切到 `empty_recent_tickets.sql` 后按 `Enter`,确认空结果 +9. 切到 `bad_syntax.sql` 后按 `Enter`,确认错误结果持续留在工作区 +10. 切到 `ticket_export_preview.sql` 后按 `Enter`,成功后按 `x` 或 `j`,确认导出反馈和 `/tmp` 路径 + +## 常见困惑 + +- `Inserted a newline. Press Esc, then Enter to run.`:表示你已经按了 `i` 进入编辑模式,然后按了 `Enter`;这时 `Enter` 不会执行查询,而是插入换行。按 `Esc` 退出编辑模式后,再按 `Enter` 才是执行查询。 +- 当前不能自定义输入数据库连接:这是当前范围外能力。当前 TUI 只提供内置的 `sqlite-local`、Postgres、MySQL 示例连接,用于验证工作台流程和状态反馈。 +- 如果觉得界面文字偏多:本轮已先做一轮 Help / Status / Connections 文案收敛;当前版本仍偏向 QA / 契约验证界面,后续再继续收敛文案。 + +## 界面验收 + +QA 可按以下步骤复核: + +1. 启动后能看到 `Connections`、`Schema Browser`、`Query Editor`、`Results`、`Inspector`、`Status & Activity` 六个区域 +2. 默认活动连接应为 `sqlite-local`,且 `Connections` 列表中能区分当前连接、可选连接和失败连接 +3. 使用 `2` 进入 `Connections` 视图后,可用 `↑` / `↓` 移动连接选择 +4. 在 `Connections` 焦点下按 `Enter` 激活失败连接时,`Status & Activity` 与 `Inspector` 会持续显示可读错误,而不是只给瞬时提示 +5. 启动后或回到 `sqlite-local` 后,连接状态会先进入 `Loading`,随后在 `Schema Browser` 焦点下可浏览 `main.accounts` 和 `main.tickets` +6. 选中对象后,`Results` 会展示列定义,`Inspector` 会展示当前 schema / object / column 上下文 +7. 在 `Query Editor` 焦点下可用 `[` / `]` 切换查询草稿,`i` 进入基础输入模式,`Enter` 触发执行 +8. `account_ticket_summary.sql` 应返回真实结果行,`empty_recent_tickets.sql` 应显示空结果,`bad_syntax.sql` 应显示结构化错误 +9. `ticket_export_preview.sql` 执行成功后,按 `x` 或 `j` 应在工作区内看到导出成功反馈和 `/tmp` 输出路径 +10. 成功结果在 `Results` 中以表格方式展示,宽表会给出列分页提示,长结果仍可用 `↑` / `↓` 浏览 +11. 错误状态会保留可读错误信息,不退化成空白或瞬时提示 +12. 使用 `Tab` / `Shift+Tab` 时,当前焦点区域边框高亮会移动 +13. 使用 `←` / `→` 或 `1` / `2` / `3` 时,中间内容区会随视图切换 +14. 使用 `r` 时,query / export 反馈会清空并回到 `Ready` +15. 将终端缩小到低于 `100x28` 时,会出现尺寸不足提示而不是错乱布局 +16. 使用 `Esc` 可恢复默认工作台,使用 `q` 可稳定退出 + +## 当前限制 + +- `sqlite-local` 是当前唯一可在本地 runner 直接完成 live connect / inspect / query / export 的路径 +- Postgres / MySQL 连接仍以可读失败态保留,用于验证多目标上下文和错误持续可见性 +- 当前 runner 若缺少 `cargo`,需要使用已有二进制做交互 smoke;源码重建与测试需在具备 Rust 工具链的环境完成 + +共享 QA 文档: + +- `TUI_ACCEPTANCE_CHECKLIST.md` +- `TUI_TEST_STRATEGY.md` +- `TUI_REGRESSION_CHECKLIST.md` diff --git a/apps/tui/src/main.rs b/apps/tui/src/main.rs new file mode 100644 index 0000000..b501b10 --- /dev/null +++ b/apps/tui/src/main.rs @@ -0,0 +1,3053 @@ +use std::env; +use std::io::{self, stdout}; +use std::path::PathBuf; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread; +use std::time::{Duration, Instant}; + +use crossterm::{ + event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use db_app::{ + AppError, AppErrorKind, ConnectionSummary, ExportResponse, InspectPayload, OperationState, + QueryResponse, +}; +use db_config::ConnectionProfile; +use db_core::{ + ConnectionTarget, ConnectionTransport, DatabaseKind, ExportFormat, ExportRequest, + InspectRequest, QueryRequest, QuerySource, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Tabs, Wrap}, + Frame, Terminal, +}; + +fn main() -> io::Result<()> { + let mut terminal = setup_terminal()?; + let run_result = run_app(&mut terminal); + restore_terminal(&mut terminal)?; + run_result +} + +fn setup_terminal() -> io::Result>> { + enable_raw_mode()?; + let mut output = stdout(); + execute!(output, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(output); + Terminal::new(backend) +} + +fn restore_terminal( + terminal: &mut Terminal>, +) -> io::Result<()> { + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor() +} + +fn run_app(terminal: &mut Terminal>) -> io::Result<()> { + let mut app = App::default(); + app.bootstrap(); + + while !app.should_quit { + app.tick(); + terminal.draw(|frame| render(frame, &app))?; + + if event::poll(Duration::from_millis(120))? { + let current = event::read()?; + + if let Event::Key(key) = current { + if key.kind == KeyEventKind::Press { + app.handle_key(key); + } + } + } + } + + Ok(()) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WorkspaceView { + Workspace, + Connections, + Help, +} + +impl WorkspaceView { + const ALL: [WorkspaceView; 3] = [ + WorkspaceView::Workspace, + WorkspaceView::Connections, + WorkspaceView::Help, + ]; + + fn label(self) -> &'static str { + match self { + WorkspaceView::Workspace => "Workspace", + WorkspaceView::Connections => "Connections", + WorkspaceView::Help => "Help", + } + } + + fn index(self) -> usize { + match self { + WorkspaceView::Workspace => 0, + WorkspaceView::Connections => 1, + WorkspaceView::Help => 2, + } + } + + fn next(self) -> Self { + match self { + WorkspaceView::Workspace => WorkspaceView::Connections, + WorkspaceView::Connections => WorkspaceView::Help, + WorkspaceView::Help => WorkspaceView::Workspace, + } + } + + fn previous(self) -> Self { + match self { + WorkspaceView::Workspace => WorkspaceView::Help, + WorkspaceView::Connections => WorkspaceView::Workspace, + WorkspaceView::Help => WorkspaceView::Connections, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SurfaceState { + Ready, + Loading, + Error, +} + +impl SurfaceState { + fn label(self) -> &'static str { + match self { + SurfaceState::Ready => "Ready", + SurfaceState::Loading => "Loading", + SurfaceState::Error => "Error", + } + } + + fn accent(self) -> Color { + match self { + SurfaceState::Ready => Color::Cyan, + SurfaceState::Loading => Color::Yellow, + SurfaceState::Error => Color::Red, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum FocusArea { + Connections, + Schema, + Editor, + Results, + Inspector, + Status, +} + +impl FocusArea { + const ALL: [FocusArea; 6] = [ + FocusArea::Connections, + FocusArea::Schema, + FocusArea::Editor, + FocusArea::Results, + FocusArea::Inspector, + FocusArea::Status, + ]; + + fn label(self) -> &'static str { + match self { + FocusArea::Connections => "Connections", + FocusArea::Schema => "Schema Browser", + FocusArea::Editor => "Query Editor", + FocusArea::Results => "Results", + FocusArea::Inspector => "Inspector", + FocusArea::Status => "Status", + } + } + + fn index(self) -> usize { + match self { + FocusArea::Connections => 0, + FocusArea::Schema => 1, + FocusArea::Editor => 2, + FocusArea::Results => 3, + FocusArea::Inspector => 4, + FocusArea::Status => 5, + } + } + + fn next(self) -> Self { + Self::ALL[(self.index() + 1) % Self::ALL.len()] + } + + fn previous(self) -> Self { + Self::ALL[(self.index() + Self::ALL.len() - 1) % Self::ALL.len()] + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ConnectionHealth { + Loading, + Healthy, + Ready, + Failed, +} + +impl ConnectionHealth { + fn label(self) -> &'static str { + match self { + ConnectionHealth::Loading => "Loading", + ConnectionHealth::Healthy => "Healthy", + ConnectionHealth::Ready => "Ready", + ConnectionHealth::Failed => "Failed", + } + } + + fn accent(self) -> Color { + match self { + ConnectionHealth::Loading => Color::Yellow, + ConnectionHealth::Healthy => Color::Green, + ConnectionHealth::Ready => Color::Yellow, + ConnectionHealth::Failed => Color::Red, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SchemaState { + Ready, + Empty, + Restricted, +} + +impl SchemaState { + fn label(self) -> &'static str { + match self { + SchemaState::Ready => "ready", + SchemaState::Empty => "empty", + SchemaState::Restricted => "restricted", + } + } + + fn accent(self) -> Color { + match self { + SchemaState::Ready => Color::Gray, + SchemaState::Empty => Color::Yellow, + SchemaState::Restricted => Color::Red, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ObjectKind { + Table, + View, +} + +impl ObjectKind { + fn label(self) -> &'static str { + match self { + ObjectKind::Table => "table", + ObjectKind::View => "view", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct BrowserColumn { + name: &'static str, + data_type: &'static str, + nullable: bool, + key: &'static str, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct BrowserObject { + name: &'static str, + kind: ObjectKind, + columns: &'static [BrowserColumn], + note: &'static str, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct BrowserSchema { + name: &'static str, + state: SchemaState, + objects: &'static [BrowserObject], + message: &'static str, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum BrowserRow { + Schema(usize), + Object { schema_index: usize, object_index: usize }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum EditorMode { + Navigate, + Insert, +} + +impl EditorMode { + fn label(self) -> &'static str { + match self { + EditorMode::Navigate => "Navigate", + EditorMode::Insert => "Insert", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct QueryDraft { + name: &'static str, + sql: String, + detail: &'static str, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum QueryRunState { + Idle, + Running, + Success, + Empty, + Error, +} + +impl QueryRunState { + fn label(self) -> &'static str { + match self { + QueryRunState::Idle => "Idle", + QueryRunState::Running => "Running", + QueryRunState::Success => "Success", + QueryRunState::Empty => "Empty", + QueryRunState::Error => "Error", + } + } + + fn accent(self) -> Color { + match self { + QueryRunState::Idle => Color::Gray, + QueryRunState::Running => Color::Yellow, + QueryRunState::Success => Color::Green, + QueryRunState::Empty => Color::Yellow, + QueryRunState::Error => Color::Red, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ExportRunState { + Idle, + Running, + Success, + Error, +} + +impl ExportRunState { + fn label(self) -> &'static str { + match self { + ExportRunState::Idle => "Idle", + ExportRunState::Running => "Running", + ExportRunState::Success => "Success", + ExportRunState::Error => "Error", + } + } + + fn accent(self) -> Color { + match self { + ExportRunState::Idle => Color::Gray, + ExportRunState::Running => Color::Yellow, + ExportRunState::Success => Color::Green, + ExportRunState::Error => Color::Red, + } + } +} + +enum WorkerMessage { + ConnectionLoaded { + run_id: u64, + connection_index: usize, + result: Result, + duration_ms: u128, + }, + QueryFinished { + run_id: u64, + request: QueryRequest, + result: Result, + duration_ms: u128, + }, + ExportFinished { + run_id: u64, + result: Result, + duration_ms: u128, + }, +} + +struct ConnectionLoadResult { + summary: ConnectionSummary, + browser_schemas: &'static [BrowserSchema], +} + +struct ConnectionRecord { + profile: ConnectionProfile, + summary: ConnectionSummary, + environment_label: &'static str, + database_label: &'static str, + mode_label: &'static str, + validation_label: String, + status: ConnectionHealth, + browser_schemas: &'static [BrowserSchema], + expanded_schemas: Vec, + selected_browser_row: usize, + details: &'static [&'static str], + failure_message: Option, +} + +impl ConnectionRecord { + fn subtitle(&self) -> String { + format!( + "{} · {} · {}", + self.summary.driver, self.environment_label, self.mode_label + ) + } + + fn endpoint_summary(&self) -> String { + format!("{} / {}", self.summary.endpoint, self.database_label) + } + + fn visible_browser_rows(&self) -> Vec { + let mut rows = Vec::new(); + + for (schema_index, schema) in self.browser_schemas.iter().enumerate() { + rows.push(BrowserRow::Schema(schema_index)); + + if self.expanded_schemas[schema_index] && schema.state == SchemaState::Ready { + rows.extend( + schema + .objects + .iter() + .enumerate() + .map(|(object_index, _)| BrowserRow::Object { + schema_index, + object_index, + }), + ); + } + } + + rows + } + + fn clamp_browser_selection(&mut self) { + let max_index = self.visible_browser_rows().len().saturating_sub(1); + self.selected_browser_row = self.selected_browser_row.min(max_index); + } + + fn selected_browser_row(&self) -> BrowserRow { + self.visible_browser_rows() + .get(self.selected_browser_row) + .copied() + .unwrap_or(BrowserRow::Schema(0)) + } + + fn selected_schema(&self) -> &BrowserSchema { + match self.selected_browser_row() { + BrowserRow::Schema(index) => &self.browser_schemas[index], + BrowserRow::Object { schema_index, .. } => &self.browser_schemas[schema_index], + } + } + + fn selected_object(&self) -> Option<&BrowserObject> { + match self.selected_browser_row() { + BrowserRow::Schema(_) => None, + BrowserRow::Object { + schema_index, + object_index, + } => Some(&self.browser_schemas[schema_index].objects[object_index]), + } + } + + fn move_browser_selection(&mut self, delta: isize) -> String { + self.selected_browser_row = + cycle_index(self.selected_browser_row, self.visible_browser_rows().len(), delta); + + match self.selected_browser_row() { + BrowserRow::Schema(index) => { + let schema = &self.browser_schemas[index]; + format!( + "Schema selection moved to {} ({}).", + schema.name, + schema.state.label() + ) + } + BrowserRow::Object { + schema_index, + object_index, + } => { + let schema = &self.browser_schemas[schema_index]; + let object = &schema.objects[object_index]; + format!("Object selection moved to {}.{}.", schema.name, object.name) + } + } + } + + fn activate_browser_selection(&mut self) -> String { + match self.selected_browser_row() { + BrowserRow::Schema(index) => { + let schema = &self.browser_schemas[index]; + + match schema.state { + SchemaState::Ready => { + if schema.objects.is_empty() { + return format!("Schema {} has no objects.", schema.name); + } + + self.expanded_schemas[index] = !self.expanded_schemas[index]; + self.clamp_browser_selection(); + + if self.expanded_schemas[index] { + format!("Expanded schema {}.", schema.name) + } else { + format!("Collapsed schema {}.", schema.name) + } + } + SchemaState::Empty => { + format!("Schema {} is empty. {}", schema.name, schema.message) + } + SchemaState::Restricted => { + format!("Schema {} is restricted. {}", schema.name, schema.message) + } + } + } + BrowserRow::Object { + schema_index, + object_index, + } => { + let schema = &self.browser_schemas[schema_index]; + let object = &schema.objects[object_index]; + format!( + "Inspecting {}.{} with {} columns.", + schema.name, + object.name, + object.columns.len() + ) + } + } + } + + fn browser_status_message(&self) -> &'static str { + let schema = self.selected_schema(); + + match schema.state { + SchemaState::Ready => { + if self.selected_object().is_some() { + "Browser is aligned with the selected database object." + } else { + "Select or expand a schema row to browse tables, views, and columns." + } + } + SchemaState::Empty => schema.message, + SchemaState::Restricted => schema.message, + } + } + + fn runtime_profile(&self) -> ConnectionProfile { + let password = self + .profile + .password_env_var + .as_ref() + .and_then(|name| env::var(name).ok()); + + self.profile.clone().with_password(password) + } +} + +struct App { + should_quit: bool, + view: WorkspaceView, + focus: FocusArea, + surface_state: SurfaceState, + connections: Vec, + active_connection: usize, + selected_connection: usize, + query_drafts: Vec, + selected_query: usize, + editor_mode: EditorMode, + query_run_state: QueryRunState, + export_run_state: ExportRunState, + worker_tx: Sender, + worker_rx: Receiver, + last_query_response: Option, + last_query_error: Option, + last_query_duration_ms: Option, + last_export_response: Option, + last_export_error: Option, + last_export_duration_ms: Option, + exportable_query_request: Option, + next_run_id: u64, + pending_connection_run_id: Option, + pending_query_run_id: Option, + pending_export_run_id: Option, + selected_result_row: usize, + result_row_offset: usize, + result_col_offset: usize, + activity_message: String, +} + +impl Default for App { + fn default() -> Self { + let (worker_tx, worker_rx) = mpsc::channel(); + let sqlite_profile = sqlite_profile(); + let postgres_profile = postgres_profile(); + let mysql_profile = mysql_profile(); + let connections = vec![ + ConnectionRecord { + profile: sqlite_profile.clone(), + summary: ConnectionSummary::from(&sqlite_profile), + environment_label: "Local file", + database_label: "main", + mode_label: "Evidence review", + validation_label: String::from("Live local demo"), + status: ConnectionHealth::Ready, + browser_schemas: &SQLITE_SCHEMAS, + expanded_schemas: vec![true, false], + selected_browser_row: 1, + details: &[ + "Single-file live demo backed by db-app and the SQLite fixture.", + "Best path for inspect → query → export in this runner.", + "Errors stay visible in the workspace.", + ], + failure_message: None, + }, + ConnectionRecord { + profile: postgres_profile.clone(), + summary: ConnectionSummary::from(&postgres_profile), + environment_label: "Production", + database_label: "analytics", + mode_label: "Read-only audit", + validation_label: String::from("Unavailable in local runner"), + status: ConnectionHealth::Failed, + browser_schemas: &POSTGRES_SCHEMAS, + expanded_schemas: vec![true, false, false, false], + selected_browser_row: 1, + details: &[ + "Production profile stays visible for layout review.", + "This runner has no live network credentials.", + "Use sqlite-local for end-to-end verification.", + ], + failure_message: Some(String::from( + "Live Postgres access is not available in this local runner. Switch to sqlite-local for a working end-to-end path.", + )), + }, + ConnectionRecord { + profile: mysql_profile.clone(), + summary: ConnectionSummary::from(&mysql_profile), + environment_label: "Staging", + database_label: "qa_demo", + mode_label: "Write enabled", + validation_label: String::from("Unavailable in local runner"), + status: ConnectionHealth::Failed, + browser_schemas: &MYSQL_SCHEMAS, + expanded_schemas: vec![true, false], + selected_browser_row: 1, + details: &[ + "Staging profile stays visible for multi-target review.", + "Network targets stay in a readable failed state here.", + "Use sqlite-local for the live demo path.", + ], + failure_message: Some(String::from( + "Live MySQL access is not available in this local runner. Use sqlite-local for end-to-end query and export verification.", + )), + }, + ]; + + let query_drafts = vec![ + QueryDraft { + name: "account_ticket_summary.sql", + sql: String::from( + "select\n a.id as account_id,\n a.display_name,\n count(t.id) as ticket_count,\n sum(case when t.status = 'open' then 1 else 0 end) as open_ticket_count\nfrom accounts a\nleft join tickets t on t.account_id = a.id\ngroup by a.id, a.display_name\norder by a.id;", + ), + detail: "Success state keeps execution detail adjacent to the real result table.", + }, + QueryDraft { + name: "ticket_export_preview.sql", + sql: String::from( + "select\n t.id as ticket_id,\n a.email,\n t.title,\n t.status,\n t.amount_cents,\n t.created_at\nfrom tickets t\njoin accounts a on a.id = t.account_id\norder by t.id;", + ), + detail: "Use [ and ] in Results focus to page across wide real result columns.", + }, + QueryDraft { + name: "empty_recent_tickets.sql", + sql: String::from( + "select\n id,\n status,\n created_at\nfrom tickets\nwhere created_at > '2026-04-01T00:00:00Z'\n and status = 'open'\norder by created_at desc;", + ), + detail: "Empty results stay distinct from query failures and remain exportable.", + }, + QueryDraft { + name: "bad_syntax.sql", + sql: String::from( + "select id, status\nform tickets\nwhere status = 'open';", + ), + detail: "Error summary stays visible in the workspace with the offending query buffer.", + }, + ]; + + Self { + should_quit: false, + view: WorkspaceView::Workspace, + focus: FocusArea::Editor, + surface_state: SurfaceState::Ready, + connections, + active_connection: 0, + selected_connection: 0, + query_drafts, + selected_query: 0, + editor_mode: EditorMode::Navigate, + query_run_state: QueryRunState::Idle, + export_run_state: ExportRunState::Idle, + worker_tx, + worker_rx, + last_query_response: None, + last_query_error: None, + last_query_duration_ms: None, + last_export_response: None, + last_export_error: None, + last_export_duration_ms: None, + exportable_query_request: None, + next_run_id: 1, + pending_connection_run_id: None, + pending_query_run_id: None, + pending_export_run_id: None, + selected_result_row: 0, + result_row_offset: 0, + result_col_offset: 0, + activity_message: String::from( + "SQLite workspace ready. [ ] switches drafts, Enter runs, x/j exports.", + ), + } + } +} + +impl App { + fn bootstrap(&mut self) { + self.begin_connection_load(self.active_connection, true); + } + + fn handle_key(&mut self, key: KeyEvent) { + if self.editor_mode == EditorMode::Insert { + self.handle_editor_insert_key(key); + return; + } + + match key.code { + KeyCode::Char('q') => { + self.should_quit = true; + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.should_quit = true; + } + KeyCode::Tab => { + self.focus = self.focus.next(); + self.activity_message = String::from("Focus moved to the next panel."); + } + KeyCode::BackTab => { + self.focus = self.focus.previous(); + self.activity_message = String::from("Focus moved to the previous panel."); + } + KeyCode::Left => { + self.view = self.view.previous(); + self.activity_message = String::from("View changed."); + } + KeyCode::Right => { + self.view = self.view.next(); + self.activity_message = String::from("View changed."); + } + KeyCode::Esc => { + self.view = WorkspaceView::Workspace; + self.focus = FocusArea::Editor; + self.editor_mode = EditorMode::Navigate; + self.update_surface_state(); + self.activity_message = String::from("Returned to the default workspace."); + } + KeyCode::Char('1') => { + self.view = WorkspaceView::Workspace; + self.activity_message = String::from("Workspace view active."); + } + KeyCode::Char('2') => { + self.view = WorkspaceView::Connections; + self.focus = FocusArea::Connections; + self.activity_message = String::from("Connections view active."); + } + KeyCode::Char('3') => { + self.view = WorkspaceView::Help; + self.activity_message = String::from("Help view active."); + } + KeyCode::Char('r') => { + self.clear_result_context(); + self.update_surface_state(); + self.activity_message = String::from("Execution state reset to ready."); + } + KeyCode::Char('x') => self.start_export(ExportFormat::Csv), + KeyCode::Char('j') => self.start_export(ExportFormat::Json), + KeyCode::Char('i') if self.focus == FocusArea::Editor => { + self.editor_mode = EditorMode::Insert; + self.activity_message = String::from("Editor insert mode enabled. Esc exits insert mode."); + } + KeyCode::Char('[') => self.handle_bracket_key(-1), + KeyCode::Char(']') => self.handle_bracket_key(1), + KeyCode::Enter => { + if self.focus == FocusArea::Connections { + self.activate_selected_connection(); + } else if self.focus == FocusArea::Schema { + let message = self.active_connection_record_mut().activate_browser_selection(); + self.activity_message = message; + self.update_surface_state(); + } else if self.focus == FocusArea::Editor { + self.start_query_execution(); + } + } + KeyCode::Up => self.move_selection(-1), + KeyCode::Down => self.move_selection(1), + _ => {} + } + } + + fn handle_editor_insert_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc => { + self.editor_mode = EditorMode::Navigate; + self.activity_message = String::from("Editor back to navigate mode."); + } + KeyCode::Backspace => { + self.current_query_draft_mut().sql.pop(); + self.activity_message = String::from("Deleted the last character."); + } + KeyCode::Enter => { + self.current_query_draft_mut().sql.push('\n'); + self.activity_message = String::from("Inserted a newline. Press Esc, then Enter to run."); + } + KeyCode::Char(ch) if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => { + self.current_query_draft_mut().sql.push(ch); + } + _ => {} + } + } + + fn tick(&mut self) { + while let Ok(message) = self.worker_rx.try_recv() { + match message { + WorkerMessage::ConnectionLoaded { + run_id, + connection_index, + result, + duration_ms, + } => { + if Some(run_id) == self.pending_connection_run_id { + self.apply_connection_result(connection_index, result, duration_ms); + } + } + WorkerMessage::QueryFinished { + run_id, + request, + result, + duration_ms, + } => { + if Some(run_id) == self.pending_query_run_id { + self.apply_query_result(request, result, duration_ms); + } + } + WorkerMessage::ExportFinished { + run_id, + result, + duration_ms, + } => { + if Some(run_id) == self.pending_export_run_id { + self.apply_export_result(result, duration_ms); + } + } + } + } + } + + fn handle_bracket_key(&mut self, delta: isize) { + match self.focus { + FocusArea::Editor => { + self.selected_query = cycle_index(self.selected_query, self.query_drafts.len(), delta); + self.clear_result_context(); + self.update_surface_state(); + self.activity_message = format!( + "Selected query changed to {}.", + self.current_query_draft().name + ); + } + FocusArea::Results => { + let column_count = self.current_result_columns().len(); + + if column_count <= RESULT_COLUMN_WINDOW { + self.activity_message = + String::from("Current result set fits without horizontal paging."); + return; + } + + let max_offset = column_count.saturating_sub(RESULT_COLUMN_WINDOW); + + if delta.is_negative() { + self.result_col_offset = self.result_col_offset.saturating_sub(1); + } else { + self.result_col_offset = (self.result_col_offset + 1).min(max_offset); + } + + self.activity_message = format!( + "Result columns paged to {}-{} of {}.", + self.result_col_offset + 1, + (self.result_col_offset + RESULT_COLUMN_WINDOW).min(column_count), + column_count + ); + } + _ => {} + } + } + + fn move_selection(&mut self, delta: isize) { + match self.focus { + FocusArea::Connections => { + self.selected_connection = + cycle_index(self.selected_connection, self.connections.len(), delta); + let selected_name = self.selected_connection_record().summary.profile_name.clone(); + self.activity_message = + format!("Selected connection moved to {selected_name}. Press Enter to activate it."); + } + FocusArea::Schema => { + let message = self.active_connection_record_mut().move_browser_selection(delta); + self.activity_message = message; + self.update_surface_state(); + } + FocusArea::Results => { + let row_count = self.current_result_rows().len(); + + if row_count == 0 { + self.activity_message = String::from("Current result set has no rows to navigate."); + return; + } + + self.selected_result_row = cycle_index(self.selected_result_row, row_count, delta); + self.ensure_result_row_visible(); + self.activity_message = format!( + "Result row moved to {} of {}.", + self.selected_result_row + 1, + row_count + ); + } + _ => {} + } + } + + fn activate_selected_connection(&mut self) { + self.begin_connection_load(self.selected_connection, false); + } + + fn start_query_execution(&mut self) { + if self.active_connection_record().status == ConnectionHealth::Loading { + self.last_query_error = Some(AppError { + operation: "query", + kind: AppErrorKind::Connection, + message: String::from( + "Query execution is unavailable while the active connection is still loading.", + ), + target: Some(self.active_connection_record().summary.clone()), + }); + self.last_query_response = None; + self.last_query_duration_ms = None; + self.query_run_state = QueryRunState::Error; + self.update_surface_state(); + self.activity_message = self.last_query_error.as_ref().unwrap().message.clone(); + return; + } + + if self.active_connection_record().status == ConnectionHealth::Failed { + self.last_query_error = Some(AppError { + operation: "query", + kind: AppErrorKind::Connection, + message: String::from( + "Query execution blocked by the active connection failure. Switch targets or use sqlite-local.", + ), + target: Some(self.active_connection_record().summary.clone()), + }); + self.last_query_response = None; + self.last_query_duration_ms = None; + self.query_run_state = QueryRunState::Error; + self.update_surface_state(); + self.activity_message = self.last_query_error.as_ref().unwrap().message.clone(); + return; + } + + if self.active_connection_record().selected_schema().state == SchemaState::Restricted { + self.last_query_error = Some(AppError { + operation: "query", + kind: AppErrorKind::Inspect, + message: String::from( + "Query execution blocked because the selected schema is restricted.", + ), + target: Some(self.active_connection_record().summary.clone()), + }); + self.last_query_response = None; + self.last_query_duration_ms = None; + self.query_run_state = QueryRunState::Error; + self.update_surface_state(); + self.activity_message = self.last_query_error.as_ref().unwrap().message.clone(); + return; + } + + let request: QueryRequest = match QueryRequest::new( + self.current_query_draft().sql.clone(), + QuerySource::Inline, + Vec::new(), + ) { + Ok(request) => request, + Err(error) => { + self.last_query_error = Some(AppError { + operation: "query", + kind: AppErrorKind::Validation, + message: error.to_string(), + target: Some(self.active_connection_record().summary.clone()), + }); + self.last_query_response = None; + self.last_query_duration_ms = None; + self.query_run_state = QueryRunState::Error; + self.update_surface_state(); + self.activity_message = self.last_query_error.as_ref().unwrap().message.clone(); + return; + } + }; + + let profile = self.active_connection_record().runtime_profile(); + let sender = self.worker_tx.clone(); + let request_for_worker = request.clone(); + let run_id = self.next_run_id; + self.next_run_id += 1; + + self.clear_export_feedback(); + self.query_run_state = QueryRunState::Running; + self.last_query_response = None; + self.last_query_error = None; + self.last_query_duration_ms = None; + self.exportable_query_request = None; + self.pending_query_run_id = Some(run_id); + self.update_surface_state(); + self.activity_message = format!( + "Running {} against {}.", + self.current_query_draft().name, + self.active_connection_record().summary.profile_name + ); + + thread::spawn(move || { + let started_at = Instant::now(); + let result = db_app::query(&profile, &request_for_worker); + let _ = sender.send(WorkerMessage::QueryFinished { + run_id, + request, + result, + duration_ms: started_at.elapsed().as_millis(), + }); + }); + } + + fn start_export(&mut self, format: ExportFormat) { + if self.query_run_state == QueryRunState::Running { + self.activity_message = + String::from("Wait for the current query to finish before exporting."); + return; + } + + let Some(request) = self.exportable_query_request.clone() else { + self.export_run_state = ExportRunState::Error; + self.last_export_error = Some(AppError { + operation: "export", + kind: AppErrorKind::Export, + message: String::from( + "Export requires a successful row-returning query result in the current workspace.", + ), + target: Some(self.active_connection_record().summary.clone()), + }); + self.last_export_response = None; + self.last_export_duration_ms = None; + self.update_surface_state(); + self.activity_message = self.last_export_error.as_ref().unwrap().message.clone(); + return; + }; + + let export_request = self.default_export_request(format); + let profile = self.active_connection_record().runtime_profile(); + let sender = self.worker_tx.clone(); + let run_id = self.next_run_id; + self.next_run_id += 1; + + self.export_run_state = ExportRunState::Running; + self.last_export_error = None; + self.last_export_response = None; + self.last_export_duration_ms = None; + self.pending_export_run_id = Some(run_id); + self.update_surface_state(); + self.activity_message = format!( + "Exporting current result to {}.", + export_request.output_path.display() + ); + + thread::spawn(move || { + let started_at = Instant::now(); + let result = db_app::export(&profile, &request, &export_request); + let _ = sender.send(WorkerMessage::ExportFinished { + run_id, + result, + duration_ms: started_at.elapsed().as_millis(), + }); + }); + } + + fn update_surface_state(&mut self) { + let active = self.active_connection_record(); + + self.surface_state = if active.status == ConnectionHealth::Failed + || active.selected_schema().state == SchemaState::Restricted + || self.query_run_state == QueryRunState::Error + || self.export_run_state == ExportRunState::Error + { + SurfaceState::Error + } else if active.status == ConnectionHealth::Loading + || self.query_run_state == QueryRunState::Running + || self.export_run_state == ExportRunState::Running + { + SurfaceState::Loading + } else { + SurfaceState::Ready + }; + } + + fn current_query_draft(&self) -> &QueryDraft { + &self.query_drafts[self.selected_query] + } + + fn current_query_draft_mut(&mut self) -> &mut QueryDraft { + &mut self.query_drafts[self.selected_query] + } + + fn current_result_columns(&self) -> &[String] { + self.last_query_response + .as_ref() + .map(|response| response.result.columns.as_slice()) + .unwrap_or(&[]) + } + + fn current_result_rows(&self) -> &[Vec] { + self.last_query_response + .as_ref() + .map(|response| response.result.rows.as_slice()) + .unwrap_or(&[]) + } + + fn visible_result_column_range(&self) -> (usize, usize) { + let columns = self.current_result_columns(); + + if columns.is_empty() { + return (0, 0); + } + + let start = self.result_col_offset.min(columns.len().saturating_sub(1)); + let end = (start + RESULT_COLUMN_WINDOW).min(columns.len()); + (start, end) + } + + fn ensure_result_row_visible(&mut self) { + if self.selected_result_row < self.result_row_offset { + self.result_row_offset = self.selected_result_row; + } else if self.selected_result_row >= self.result_row_offset + RESULT_ROW_WINDOW { + self.result_row_offset = self.selected_result_row + 1 - RESULT_ROW_WINDOW; + } + } + + fn begin_connection_load(&mut self, connection_index: usize, is_startup: bool) { + self.active_connection = connection_index; + self.selected_connection = connection_index; + self.view = WorkspaceView::Workspace; + self.focus = FocusArea::Editor; + self.clear_result_context(); + + let run_id = self.next_run_id; + self.next_run_id += 1; + self.pending_connection_run_id = Some(run_id); + + let record = &mut self.connections[connection_index]; + record.status = ConnectionHealth::Loading; + record.validation_label = String::from("Connecting via shared app…"); + record.failure_message = None; + record.browser_schemas = loading_browser_schemas(); + record.expanded_schemas = vec![true]; + record.selected_browser_row = 0; + + let profile = record.runtime_profile(); + let sender = self.worker_tx.clone(); + let profile_name = record.summary.profile_name.clone(); + self.update_surface_state(); + self.activity_message = if is_startup { + format!("Loading {profile_name} connection and schema.") + } else { + format!("Activating {profile_name} and refreshing schema.") + }; + + thread::spawn(move || { + let started_at = Instant::now(); + let result = load_connection_context(&profile); + let _ = sender.send(WorkerMessage::ConnectionLoaded { + run_id, + connection_index, + result, + duration_ms: started_at.elapsed().as_millis(), + }); + }); + } + + fn query_execution_title(&self) -> &'static str { + match self.query_run_state { + QueryRunState::Idle => "Ready to execute", + QueryRunState::Running => "Query is running", + QueryRunState::Success => "Query succeeded", + QueryRunState::Empty => "Query returned no rows", + QueryRunState::Error => "Query failed", + } + } + + fn query_execution_copy(&self) -> String { + let draft = self.current_query_draft(); + + match self.query_run_state { + QueryRunState::Idle => String::from( + "Enter runs the draft. Press i to edit. After success, x/j exports.", + ), + QueryRunState::Running => format!( + "Executing {} against {}…", + draft.name, + self.active_connection_record().summary.profile_name + ), + QueryRunState::Success => { + let duration_ms = self.last_query_duration_ms.unwrap_or_default(); + match self + .last_query_response + .as_ref() + .and_then(|response| response.result.rows_affected) + { + Some(rows_affected) => format!( + "{rows_affected} rows affected in {duration_ms} ms. {}", + draft.detail + ), + None => format!( + "{} rows returned in {} ms. {}", + self.current_result_rows().len(), + duration_ms, + draft.detail + ), + } + } + QueryRunState::Empty => format!( + "0 rows returned in {} ms. {}", + self.last_query_duration_ms.unwrap_or_default(), + draft.detail + ), + QueryRunState::Error => self + .last_query_error + .as_ref() + .map(|error| error.message.clone()) + .unwrap_or_else(|| String::from("Query failed without structured error detail.")), + } + } + + fn export_execution_copy(&self) -> String { + match self.export_run_state { + ExportRunState::Idle => { + String::from("Run a row-returning query before exporting.") + } + ExportRunState::Running => String::from( + "Export is running. Results stay visible.", + ), + ExportRunState::Success => self + .last_export_response + .as_ref() + .map(|response| { + format!( + "{} rows exported to {} as {} in {} ms.", + response.row_count, + response.export.output_path, + response.export.format, + self.last_export_duration_ms.unwrap_or_default() + ) + }) + .unwrap_or_else(|| String::from("Export finished successfully.")), + ExportRunState::Error => self + .last_export_error + .as_ref() + .map(|error| error.message.clone()) + .unwrap_or_else(|| String::from("Export failed without structured detail.")), + } + } + + fn active_connection_record(&self) -> &ConnectionRecord { + &self.connections[self.active_connection] + } + + fn active_connection_record_mut(&mut self) -> &mut ConnectionRecord { + &mut self.connections[self.active_connection] + } + + fn selected_connection_record(&self) -> &ConnectionRecord { + &self.connections[self.selected_connection] + } + + fn clear_result_context(&mut self) { + self.query_run_state = QueryRunState::Idle; + self.last_query_response = None; + self.last_query_error = None; + self.last_query_duration_ms = None; + self.exportable_query_request = None; + self.pending_query_run_id = None; + self.selected_result_row = 0; + self.result_row_offset = 0; + self.result_col_offset = 0; + self.clear_export_feedback(); + } + + fn clear_export_feedback(&mut self) { + self.export_run_state = ExportRunState::Idle; + self.last_export_response = None; + self.last_export_error = None; + self.last_export_duration_ms = None; + self.pending_export_run_id = None; + } + + fn apply_query_result( + &mut self, + request: QueryRequest, + result: Result, + duration_ms: u128, + ) { + self.pending_query_run_id = None; + self.last_query_duration_ms = Some(duration_ms); + self.selected_result_row = 0; + self.result_row_offset = 0; + self.result_col_offset = 0; + + match result { + Ok(response) => { + self.query_run_state = match response.state() { + OperationState::Success => QueryRunState::Success, + OperationState::Empty => QueryRunState::Empty, + OperationState::Error => QueryRunState::Error, + OperationState::Running => QueryRunState::Running, + }; + self.exportable_query_request = if response.result.rows_affected.is_none() { + Some(request) + } else { + None + }; + self.last_query_response = Some(response); + self.last_query_error = None; + self.activity_message = String::from("Query execution finished and workspace state updated."); + } + Err(error) => { + self.query_run_state = QueryRunState::Error; + self.exportable_query_request = None; + self.last_query_response = None; + self.last_query_error = Some(error); + self.activity_message = self + .last_query_error + .as_ref() + .map(|error| error.message.clone()) + .unwrap_or_else(|| String::from("Query execution failed.")); + } + } + + self.update_surface_state(); + } + + fn apply_connection_result( + &mut self, + connection_index: usize, + result: Result, + duration_ms: u128, + ) { + self.pending_connection_run_id = None; + let record = &mut self.connections[connection_index]; + + match result { + Ok(load_result) => { + record.summary = load_result.summary; + record.status = ConnectionHealth::Healthy; + record.validation_label = format!("Connected · inspected in {duration_ms} ms"); + record.failure_message = None; + record.browser_schemas = load_result.browser_schemas; + record.expanded_schemas = vec![true; record.browser_schemas.len()]; + record.selected_browser_row = if record.browser_schemas.is_empty() { 0 } else { 0 }; + self.activity_message = format!( + "Active connection {} loaded with {} schema entries.", + record.summary.profile_name, + record.browser_schemas.len() + ); + } + Err(error) => { + record.status = ConnectionHealth::Failed; + record.validation_label = format!( + "{} failed · {} ms", + app_error_kind_label(&error.kind), + duration_ms + ); + record.failure_message = Some(error.message.clone()); + record.browser_schemas = failed_browser_schemas(error.message.as_str()); + record.expanded_schemas = vec![false; record.browser_schemas.len().max(1)]; + record.selected_browser_row = 0; + self.activity_message = format!( + "Failed to activate {}: {}", + record.summary.profile_name, error.message + ); + } + } + + self.update_surface_state(); + } + + fn apply_export_result( + &mut self, + result: Result, + duration_ms: u128, + ) { + self.pending_export_run_id = None; + self.last_export_duration_ms = Some(duration_ms); + + match result { + Ok(response) => { + self.export_run_state = ExportRunState::Success; + self.last_export_response = Some(response); + self.last_export_error = None; + self.activity_message = self.export_execution_copy(); + } + Err(error) => { + self.export_run_state = ExportRunState::Error; + self.last_export_response = None; + self.last_export_error = Some(error); + self.activity_message = self.export_execution_copy(); + } + } + + self.update_surface_state(); + } + + fn default_export_request(&self, format: ExportFormat) -> ExportRequest { + let filename = format!( + "dbtool-tui-{}-{}.{}", + sanitize_filename(self.active_connection_record().summary.profile_name.as_str()), + sanitize_filename(self.current_query_draft().name), + format.file_extension() + ); + ExportRequest { + format, + output_path: env::temp_dir().join(filename), + overwrite: true, + } + } +} + +const RESULT_ROW_WINDOW: usize = 5; +const RESULT_COLUMN_WINDOW: usize = 4; + +const SQLITE_ACCOUNTS_COLUMNS: [BrowserColumn; 5] = [ + BrowserColumn { + name: "id", + data_type: "integer", + nullable: false, + key: "pk", + }, + BrowserColumn { + name: "email", + data_type: "text", + nullable: false, + key: "unique", + }, + BrowserColumn { + name: "display_name", + data_type: "text", + nullable: true, + key: "-", + }, + BrowserColumn { + name: "is_active", + data_type: "integer", + nullable: false, + key: "-", + }, + BrowserColumn { + name: "created_at", + data_type: "text", + nullable: false, + key: "-", + }, +]; + +const SQLITE_TICKETS_COLUMNS: [BrowserColumn; 7] = [ + BrowserColumn { + name: "id", + data_type: "integer", + nullable: false, + key: "pk", + }, + BrowserColumn { + name: "account_id", + data_type: "integer", + nullable: false, + key: "fk", + }, + BrowserColumn { + name: "title", + data_type: "text", + nullable: false, + key: "-", + }, + BrowserColumn { + name: "status", + data_type: "text", + nullable: false, + key: "-", + }, + BrowserColumn { + name: "notes", + data_type: "text", + nullable: true, + key: "-", + }, + BrowserColumn { + name: "amount_cents", + data_type: "integer", + nullable: false, + key: "-", + }, + BrowserColumn { + name: "created_at", + data_type: "text", + nullable: false, + key: "-", + }, +]; + +const SQLITE_OBJECTS: [BrowserObject; 2] = [ + BrowserObject { + name: "accounts", + kind: ObjectKind::Table, + columns: &SQLITE_ACCOUNTS_COLUMNS, + note: "Account identities used by the SQLite demo workflow.", + }, + BrowserObject { + name: "tickets", + kind: ObjectKind::Table, + columns: &SQLITE_TICKETS_COLUMNS, + note: "Ticket evidence used for live query, empty result, and export rehearsal.", + }, +]; + +const SQLITE_SCHEMAS: [BrowserSchema; 2] = [ + BrowserSchema { + name: "main", + state: SchemaState::Ready, + objects: &SQLITE_OBJECTS, + message: "SQLite main schema is backed by the seeded demo database.", + }, + BrowserSchema { + name: "archive", + state: SchemaState::Empty, + objects: &[], + message: "Archive has no objects in the current SQLite demo file.", + }, +]; + +const POSTGRES_ORDERS_COLUMNS: [BrowserColumn; 4] = [ + BrowserColumn { + name: "id", + data_type: "uuid", + nullable: false, + key: "pk", + }, + BrowserColumn { + name: "customer_id", + data_type: "uuid", + nullable: false, + key: "-", + }, + BrowserColumn { + name: "status", + data_type: "text", + nullable: false, + key: "-", + }, + BrowserColumn { + name: "created_at", + data_type: "timestamptz", + nullable: false, + key: "-", + }, +]; + +const POSTGRES_CUSTOMERS_COLUMNS: [BrowserColumn; 4] = [ + BrowserColumn { + name: "id", + data_type: "uuid", + nullable: false, + key: "pk", + }, + BrowserColumn { + name: "email", + data_type: "text", + nullable: false, + key: "unique", + }, + BrowserColumn { + name: "country", + data_type: "text", + nullable: true, + key: "-", + }, + BrowserColumn { + name: "updated_at", + data_type: "timestamptz", + nullable: false, + key: "-", + }, +]; + +const POSTGRES_DAILY_ORDERS_COLUMNS: [BrowserColumn; 3] = [ + BrowserColumn { + name: "day", + data_type: "date", + nullable: false, + key: "-", + }, + BrowserColumn { + name: "order_count", + data_type: "bigint", + nullable: false, + key: "-", + }, + BrowserColumn { + name: "gross_revenue", + data_type: "numeric", + nullable: false, + key: "-", + }, +]; + +const POSTGRES_PUBLIC_OBJECTS: [BrowserObject; 2] = [ + BrowserObject { + name: "orders", + kind: ObjectKind::Table, + columns: &POSTGRES_ORDERS_COLUMNS, + note: "Primary transactional order table for recent audit queries.", + }, + BrowserObject { + name: "customers", + kind: ObjectKind::Table, + columns: &POSTGRES_CUSTOMERS_COLUMNS, + note: "Customer identity and location metadata.", + }, +]; + +const POSTGRES_ANALYTICS_OBJECTS: [BrowserObject; 1] = [BrowserObject { + name: "daily_orders", + kind: ObjectKind::View, + columns: &POSTGRES_DAILY_ORDERS_COLUMNS, + note: "Aggregated order counts used by reporting workflows.", +}]; + +const POSTGRES_SCHEMAS: [BrowserSchema; 4] = [ + BrowserSchema { + name: "public", + state: SchemaState::Ready, + objects: &POSTGRES_PUBLIC_OBJECTS, + message: "Public schema is available for core object browsing.", + }, + BrowserSchema { + name: "analytics", + state: SchemaState::Ready, + objects: &POSTGRES_ANALYTICS_OBJECTS, + message: "Analytics views are ready for read-only inspection.", + }, + BrowserSchema { + name: "scratch", + state: SchemaState::Empty, + objects: &[], + message: "Scratch currently contains no tables or views.", + }, + BrowserSchema { + name: "ops", + state: SchemaState::Restricted, + objects: &[], + message: "Permission denied while browsing ops. Request broader read access before inspecting this schema.", + }, +]; + +const MYSQL_ORDERS_COLUMNS: [BrowserColumn; 4] = [ + BrowserColumn { + name: "id", + data_type: "varchar(36)", + nullable: false, + key: "pk", + }, + BrowserColumn { + name: "status", + data_type: "varchar(32)", + nullable: false, + key: "-", + }, + BrowserColumn { + name: "amount_cents", + data_type: "bigint", + nullable: false, + key: "-", + }, + BrowserColumn { + name: "updated_at", + data_type: "datetime", + nullable: false, + key: "-", + }, +]; + +const MYSQL_ORDER_ITEM_COLUMNS: [BrowserColumn; 4] = [ + BrowserColumn { + name: "id", + data_type: "varchar(36)", + nullable: false, + key: "pk", + }, + BrowserColumn { + name: "order_id", + data_type: "varchar(36)", + nullable: false, + key: "fk", + }, + BrowserColumn { + name: "sku", + data_type: "varchar(64)", + nullable: false, + key: "-", + }, + BrowserColumn { + name: "quantity", + data_type: "int", + nullable: false, + key: "-", + }, +]; + +const MYSQL_QA_OBJECTS: [BrowserObject; 2] = [ + BrowserObject { + name: "orders", + kind: ObjectKind::Table, + columns: &MYSQL_ORDERS_COLUMNS, + note: "Primary staging order table.", + }, + BrowserObject { + name: "order_items", + kind: ObjectKind::Table, + columns: &MYSQL_ORDER_ITEM_COLUMNS, + note: "Line-item detail for staging validations.", + }, +]; + +const MYSQL_SCHEMAS: [BrowserSchema; 2] = [ + BrowserSchema { + name: "qa_demo", + state: SchemaState::Ready, + objects: &MYSQL_QA_OBJECTS, + message: "Staging object list is cached from the last successful read.", + }, + BrowserSchema { + name: "internal_ops", + state: SchemaState::Restricted, + objects: &[], + message: "Authentication failed before internal_ops could be enumerated.", + }, +]; + +fn cycle_index(current: usize, len: usize, delta: isize) -> usize { + if len == 0 { + return 0; + } + + if delta.is_negative() { + if current == 0 { + len - 1 + } else { + current - 1 + } + } else { + (current + 1) % len + } +} + +fn load_connection_context(profile: &ConnectionProfile) -> Result { + let connect_response = db_app::connect(profile)?; + let browser_schemas = build_browser_schemas(profile)?; + + Ok(ConnectionLoadResult { + summary: connect_response.target, + browser_schemas, + }) +} + +fn build_browser_schemas(profile: &ConnectionProfile) -> Result<&'static [BrowserSchema], AppError> { + let root_request = InspectRequest { + schema: None, + table: None, + }; + let root_response = db_app::inspect(profile, &root_request)?; + + let InspectPayload::Schemas(schema_items) = root_response.payload else { + return Ok(empty_browser_schemas( + "Current target did not return schema entries for the root inspect scope.", + )); + }; + + if schema_items.is_empty() { + return Ok(empty_browser_schemas( + "No schemas were returned for the current connection.", + )); + } + + let mut schemas = Vec::new(); + + for schema_item in schema_items { + let request = InspectRequest { + schema: Some(schema_item.name.clone()), + table: None, + }; + + match db_app::inspect(profile, &request) { + Ok(response) => { + let tables = match response.payload { + InspectPayload::Tables(items) => items, + _ => Vec::new(), + }; + + if tables.is_empty() { + schemas.push(BrowserSchema { + name: leak_str(schema_item.name.clone()), + state: SchemaState::Empty, + objects: leak_objects(Vec::new()), + message: leak_str(format!( + "Schema {} has no tables or views in the current target.", + schema_item.name + )), + }); + continue; + } + + let mut objects = Vec::new(); + + for table in tables { + let column_request = InspectRequest { + schema: Some(table.schema.clone()), + table: Some(table.name.clone()), + }; + + let columns = match db_app::inspect(profile, &column_request) { + Ok(column_response) => match column_response.payload { + InspectPayload::Columns(items) => items + .into_iter() + .map(|column| BrowserColumn { + name: leak_str(column.name), + data_type: leak_str(column.data_type), + nullable: column.nullable, + key: leak_str(if column.primary_key { + String::from("pk") + } else { + String::from("-") + }), + }) + .collect::>(), + _ => Vec::new(), + }, + Err(error) => { + objects.push(BrowserObject { + name: leak_str(table.name.clone()), + kind: object_kind_from_str(table.kind.as_str()), + columns: leak_columns(Vec::new()), + note: leak_str(format!( + "Column inspect failed: {}", + error.message + )), + }); + continue; + } + }; + + let column_count = columns.len(); + + objects.push(BrowserObject { + name: leak_str(table.name.clone()), + kind: object_kind_from_str(table.kind.as_str()), + columns: leak_columns(columns), + note: leak_str(format!( + "Loaded {} columns from shared app inspect.", + column_count + )), + }); + } + + schemas.push(BrowserSchema { + name: leak_str(schema_item.name.clone()), + state: SchemaState::Ready, + objects: leak_objects(objects), + message: leak_str(format!( + "Schema {} loaded from shared app inspect.", + schema_item.name + )), + }); + } + Err(error) => { + schemas.push(BrowserSchema { + name: leak_str(schema_item.name.clone()), + state: SchemaState::Restricted, + objects: leak_objects(Vec::new()), + message: leak_str(error.message), + }); + } + } + } + + Ok(leak_schemas(schemas)) +} + +fn loading_browser_schemas() -> &'static [BrowserSchema] { + leak_schemas(vec![BrowserSchema { + name: leak_str("loading"), + state: SchemaState::Empty, + objects: leak_objects(Vec::new()), + message: leak_str("Loading schema metadata from shared app…"), + }]) +} + +fn empty_browser_schemas(message: &str) -> &'static [BrowserSchema] { + leak_schemas(vec![BrowserSchema { + name: leak_str("empty"), + state: SchemaState::Empty, + objects: leak_objects(Vec::new()), + message: leak_str(message), + }]) +} + +fn failed_browser_schemas(message: &str) -> &'static [BrowserSchema] { + leak_schemas(vec![BrowserSchema { + name: leak_str("unavailable"), + state: SchemaState::Restricted, + objects: leak_objects(Vec::new()), + message: leak_str(message), + }]) +} + +fn object_kind_from_str(kind: &str) -> ObjectKind { + if kind.to_ascii_lowercase().contains("view") { + ObjectKind::View + } else { + ObjectKind::Table + } +} + +fn leak_schemas(items: Vec) -> &'static [BrowserSchema] { + Box::leak(items.into_boxed_slice()) +} + +fn leak_objects(items: Vec) -> &'static [BrowserObject] { + Box::leak(items.into_boxed_slice()) +} + +fn leak_columns(items: Vec) -> &'static [BrowserColumn] { + Box::leak(items.into_boxed_slice()) +} + +fn leak_str(value: impl Into) -> &'static str { + Box::leak(value.into().into_boxed_str()) +} + +fn app_error_kind_label(kind: &AppErrorKind) -> &'static str { + match kind { + AppErrorKind::Validation => "Validation", + AppErrorKind::Connection => "Connection", + AppErrorKind::Authentication => "Authentication", + AppErrorKind::Inspect => "Inspect", + AppErrorKind::Query => "Query", + AppErrorKind::Export => "Export", + } +} + +fn sqlite_profile() -> ConnectionProfile { + ConnectionProfile::new( + "sqlite-local", + ConnectionTarget::new( + DatabaseKind::Sqlite, + ConnectionTransport::File { + path: PathBuf::from("examples/tmp/dbtool-demo.sqlite"), + }, + None, + None, + ) + .expect("sqlite target should be valid"), + None, + ) + .expect("sqlite profile should be valid") +} + +fn postgres_profile() -> ConnectionProfile { + ConnectionProfile::new( + "reporting-postgres", + ConnectionTarget::new( + DatabaseKind::Postgres, + ConnectionTransport::Tcp { + host: String::from("reporting.internal"), + port: 55432, + }, + Some(String::from("analytics")), + Some(String::from("dbtool")), + ) + .expect("postgres target should be valid"), + Some(String::from("DBTOOL_PASSWORD")), + ) + .expect("postgres profile should be valid") +} + +fn mysql_profile() -> ConnectionProfile { + ConnectionProfile::new( + "orders-mysql", + ConnectionTarget::new( + DatabaseKind::Mysql, + ConnectionTransport::Tcp { + host: String::from("orders.internal"), + port: 53306, + }, + Some(String::from("qa_demo")), + Some(String::from("qa_readonly")), + ) + .expect("mysql target should be valid"), + Some(String::from("ORDERS_DB_PASSWORD")), + ) + .expect("mysql profile should be valid") +} + +fn sanitize_filename(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .to_string() +} + +fn render(frame: &mut Frame, app: &App) { + let area = frame.area(); + + if area.width < 100 || area.height < 28 { + render_small_terminal(frame, area); + return; + } + + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(20), + Constraint::Length(2), + ]) + .split(area); + + render_header(frame, sections[0], app); + render_body(frame, sections[1], app); + render_footer(frame, sections[2], app); +} + +fn render_small_terminal(frame: &mut Frame, area: Rect) { + let warning = Paragraph::new(Text::from(vec![ + Line::from("dbtool-tui shell requires at least 100x28."), + Line::from("Resize the terminal to inspect the full workspace layout."), + ])) + .block( + Block::default() + .title("Terminal Too Small") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)), + ) + .wrap(Wrap { trim: true }); + + frame.render_widget(warning, area); +} + +fn render_header(frame: &mut Frame, area: Rect, app: &App) { + let active = app.active_connection_record(); + let selected_schema = active.selected_schema(); + let selected_object = active.selected_object(); + + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(34), + Constraint::Min(24), + Constraint::Length(40), + ]) + .split(area); + + let title = Paragraph::new(Text::from(vec![ + Line::from(vec![ + Span::styled( + "dbtool-tui", + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + "terminal database workspace", + Style::default().fg(Color::DarkGray), + ), + ]), + Line::from(format!( + "Target: {} · Query: {}", + active.summary.profile_name, + app.current_query_draft().name + )), + ])) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)), + ); + + let tabs = Tabs::new( + WorkspaceView::ALL + .iter() + .map(|view| Line::from(view.label())) + .collect::>(), + ) + .select(app.view.index()) + .block(Block::default().borders(Borders::ALL).title("Views")) + .highlight_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); + + let selection_label = match selected_object { + Some(object) => format!("Object: {}.{}", selected_schema.name, object.name), + None => format!("Schema state: {}", selected_schema.state.label()), + }; + + let status = Paragraph::new(Text::from(vec![ + Line::from(vec![ + Span::styled("Connection ", Style::default().fg(Color::Gray)), + Span::styled( + active.status.label(), + Style::default() + .fg(active.status.accent()) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled("Execution ", Style::default().fg(Color::Gray)), + Span::styled( + app.query_run_state.label(), + Style::default() + .fg(app.query_run_state.accent()) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(selection_label), + Line::from(format!( + "{} · Focus: {}", + app.surface_state.label(), + app.focus.label() + )), + ])) + .block(Block::default().borders(Borders::ALL).title("Session")); + + frame.render_widget(title, columns[0]); + frame.render_widget(tabs, columns[1]); + frame.render_widget(status, columns[2]); +} + +fn render_body(frame: &mut Frame, area: Rect, app: &App) { + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(34), + Constraint::Min(44), + Constraint::Length(34), + ]) + .split(area); + + let left = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(11), Constraint::Min(12)]) + .split(columns[0]); + + let center = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(14), Constraint::Min(12)]) + .split(columns[1]); + + let right = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(13), Constraint::Min(12)]) + .split(columns[2]); + + render_connections(frame, left[0], app); + render_schema(frame, left[1], app); + render_editor(frame, center[0], app); + render_results(frame, center[1], app); + render_inspector(frame, right[0], app); + render_status(frame, right[1], app); +} + +fn render_footer(frame: &mut Frame, area: Rect, app: &App) { + let footer = Paragraph::new(Line::from(vec![ + Span::styled("Tab/Shift+Tab", Style::default().fg(Color::White)), + Span::raw(" focus "), + Span::styled("↑/↓", Style::default().fg(Color::White)), + Span::raw(" move "), + Span::styled("Enter", Style::default().fg(Color::White)), + Span::raw(" run/activate "), + Span::styled("i", Style::default().fg(Color::White)), + Span::raw(" insert "), + Span::styled("x/j", Style::default().fg(Color::White)), + Span::raw(" export csv/json "), + Span::styled("[ ]", Style::default().fg(Color::White)), + Span::raw(" query/columns "), + Span::styled("Esc", Style::default().fg(Color::White)), + Span::raw(" back "), + Span::styled("q", Style::default().fg(Color::White)), + Span::raw(" quit"), + ])) + .style(Style::default().fg(Color::Gray)); + + let footer_block = Block::default() + .borders(Borders::TOP) + .border_style(Style::default().fg(Color::DarkGray)) + .title(Span::styled( + app.activity_message.clone(), + Style::default().fg(Color::Gray), + )); + + frame.render_widget(footer.block(footer_block), area); +} + +fn render_connections(frame: &mut Frame, area: Rect, app: &App) { + let items = app.connections.iter().enumerate().map(|(index, connection)| { + let marker = if index == app.active_connection { + "Current" + } else { + "Available" + }; + + let style = if index == app.selected_connection { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else if connection.status == ConnectionHealth::Failed { + Style::default().fg(Color::Red) + } else if index == app.active_connection { + Style::default() + .fg(connection.status.accent()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; + + let detail_style = if index == app.selected_connection { + Style::default().fg(Color::Black) + } else { + Style::default().fg(Color::DarkGray) + }; + + ListItem::new(Text::from(vec![ + Line::from(vec![ + Span::styled(marker, style), + Span::raw(" · "), + Span::styled(connection.summary.profile_name.clone(), style), + Span::raw(" · "), + Span::styled(connection.status.label(), style), + ]), + Line::from(Span::styled(connection.subtitle(), detail_style)), + Line::from(Span::styled(connection.endpoint_summary(), detail_style)), + Line::from(Span::styled( + format!("Validation: {}", connection.validation_label), + detail_style, + )), + ])) + }); + + let widget = List::new(items.collect::>()).block(panel_block( + "Connections", + app.focus == FocusArea::Connections, + )); + + frame.render_widget(widget, area); +} + +fn render_schema(frame: &mut Frame, area: Rect, app: &App) { + let active = app.active_connection_record(); + let visible_rows = active.visible_browser_rows(); + + let items = visible_rows.iter().enumerate().map(|(row_index, row)| { + let is_selected = row_index == active.selected_browser_row; + + match row { + BrowserRow::Schema(schema_index) => { + let schema = &active.browser_schemas[*schema_index]; + let prefix = match schema.state { + SchemaState::Ready if active.expanded_schemas[*schema_index] => "▾", + SchemaState::Ready => "▸", + SchemaState::Empty => "○", + SchemaState::Restricted => "⨯", + }; + + let style = if is_selected { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(schema.state.accent()) + }; + + let detail_style = if is_selected { + Style::default().fg(Color::Black) + } else { + Style::default().fg(Color::DarkGray) + }; + + ListItem::new(Text::from(vec![ + Line::from(vec![ + Span::styled(prefix, style), + Span::raw(" "), + Span::styled(schema.name, style), + Span::raw(" · "), + Span::styled(schema.state.label(), style), + ]), + Line::from(Span::styled(schema.message, detail_style)), + ])) + } + BrowserRow::Object { + schema_index, + object_index, + } => { + let schema = &active.browser_schemas[*schema_index]; + let object = &schema.objects[*object_index]; + + let style = if is_selected { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; + + let detail_style = if is_selected { + Style::default().fg(Color::Black) + } else { + Style::default().fg(Color::DarkGray) + }; + + ListItem::new(Text::from(vec![ + Line::from(vec![ + Span::raw(" ↳ "), + Span::styled(object.name, style), + Span::raw(" · "), + Span::styled(object.kind.label(), style), + ]), + Line::from(Span::styled( + format!("{} columns · {}", object.columns.len(), object.note), + detail_style, + )), + ])) + } + } + }); + + let title = format!("Schema Browser · {}", active.summary.profile_name); + let widget = List::new(items.collect::>()) + .block(panel_block(&title, app.focus == FocusArea::Schema)); + + frame.render_widget(widget, area); +} + +fn render_editor(frame: &mut Frame, area: Rect, app: &App) { + let active = app.active_connection_record(); + let selected = app.selected_connection_record(); + let schema = active.selected_schema(); + let object = active.selected_object(); + let draft = app.current_query_draft(); + + let mut lines = Vec::new(); + lines.push(Line::from(vec![ + Span::styled("Drafts: ", Style::default().fg(Color::Gray)), + Span::styled( + app.query_drafts + .iter() + .enumerate() + .map(|(index, item)| { + if index == app.selected_query { + format!("[{}]", item.name) + } else { + item.name.to_string() + } + }) + .collect::>() + .join(" "), + Style::default().fg(Color::White), + ), + ])); + lines.push(Line::from(format!( + "Mode: {} · Target: {}", + app.editor_mode.label(), + active.summary.profile_name + ))); + lines.push(Line::from(format!( + "Execution: {}", + app.query_execution_title() + ))); + lines.push(Line::from(app.query_execution_copy())); + lines.push(Line::from("")); + + match app.view { + WorkspaceView::Workspace => { + if active.status == ConnectionHealth::Failed { + lines.push(Line::from(format!( + "-- Target: {}", + active.summary.profile_name + ))); + lines.push(Line::from( + "-- Query execution is blocked until the active connection failure is resolved.", + )); + } else { + lines.push(Line::from(format!( + "-- Target: {}", + active.summary.profile_name + ))); + lines.push(Line::from(format!("-- Schema: {}", schema.name))); + + if let Some(object) = object { + lines.push(Line::from(format!( + "-- Object: {} ({})", + object.name, + object.kind.label() + ))); + } + + for (index, line) in draft.sql.lines().enumerate() { + lines.push(Line::from(format!("{:>2} {}", index + 1, line))); + } + + if draft.sql.is_empty() { + lines.push(Line::from(" 1 ")); + } + } + } + WorkspaceView::Connections => { + lines.push(Line::from(format!( + "Selected profile: {}", + selected.summary.profile_name + ))); + lines.push(Line::from(format!("Driver: {}", selected.summary.driver))); + lines.push(Line::from(format!("Endpoint: {}", selected.summary.endpoint))); + lines.push(Line::from(format!("Database: {}", selected.database_label))); + lines.push(Line::from(format!("Mode: {}", selected.mode_label))); + lines.push(Line::from(format!("Validation: {}", selected.validation_label))); + } + WorkspaceView::Help => { + lines.push(Line::from("Query Workflow")); + lines.push(Line::from("")); + lines.push(Line::from("1. [ ] switches drafts.")); + lines.push(Line::from("2. Enter runs the current draft.")); + lines.push(Line::from("3. Press i to edit; Esc leaves edit mode.")); + lines.push(Line::from("4. Press x or j to export the current result.")); + } + } + + let widget = Paragraph::new(Text::from(lines)) + .block(panel_block("Query Editor", app.focus == FocusArea::Editor)) + .wrap(Wrap { trim: false }); + + frame.render_widget(widget, area); +} + +fn render_results(frame: &mut Frame, area: Rect, app: &App) { + let active = app.active_connection_record(); + let schema = active.selected_schema(); + + match app.view { + WorkspaceView::Help => { + let widget = Paragraph::new(Text::from(vec![ + Line::from("Query QA"), + Line::from(""), + Line::from("1. Run success, empty, and error drafts."), + Line::from("2. Verify each state stays visible."), + Line::from("3. Use [ ] to page wide columns."), + Line::from("4. Use ↑/↓ to browse result rows."), + ])) + .block(panel_block("Results", app.focus == FocusArea::Results)) + .wrap(Wrap { trim: true }); + + frame.render_widget(widget, area); + } + WorkspaceView::Connections => { + let widget = Paragraph::new(Text::from(vec![ + Line::from(format!( + "Current target: {}", + active.summary.profile_name + )), + Line::from(""), + Line::from("Execution workflow"), + Line::from("- Enter runs the current query"), + Line::from("- Running, empty, and error states stay visible"), + Line::from("- x and j export the current result"), + Line::from("- Results supports row and column paging"), + ])) + .block(panel_block("Results", app.focus == FocusArea::Results)) + .wrap(Wrap { trim: true }); + + frame.render_widget(widget, area); + } + WorkspaceView::Workspace => match app.query_run_state { + QueryRunState::Idle => { + if let Some(object) = active.selected_object() { + let rows = object.columns.iter().enumerate().map(|(index, column)| { + let style = if index == 0 { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default().fg(Color::Gray) + }; + + Row::new(vec![ + Cell::from(column.name), + Cell::from(column.data_type), + Cell::from(if column.nullable { "yes" } else { "no" }), + Cell::from(column.key), + ]) + .style(style) + }); + + let table = ratatui::widgets::Table::new( + rows.collect::>(), + [ + Constraint::Percentage(32), + Constraint::Percentage(28), + Constraint::Percentage(20), + Constraint::Percentage(20), + ], + ) + .header( + Row::new(vec!["column", "type", "nullable", "key"]).style( + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + ) + .block(panel_block("Results", app.focus == FocusArea::Results)) + .column_spacing(1); + + frame.render_widget(table, area); + } else { + let widget = Paragraph::new(Text::from(vec![ + Line::from(format!("Schema: {} ({})", schema.name, schema.state.label())), + Line::from(""), + Line::from(schema.message), + Line::from(""), + Line::from("Run a query or select a table/view to preview structure."), + ])) + .block(panel_block("Results", app.focus == FocusArea::Results)) + .wrap(Wrap { trim: true }); + + frame.render_widget(widget, area); + } + } + QueryRunState::Running => { + let widget = Paragraph::new(Text::from(vec![ + Line::from("Execution in progress"), + Line::from(""), + Line::from(app.query_execution_copy()), + ])) + .block(panel_block("Results", app.focus == FocusArea::Results)) + .wrap(Wrap { trim: true }); + + frame.render_widget(widget, area); + } + QueryRunState::Empty => { + let widget = Paragraph::new(Text::from(vec![ + Line::from("No rows returned"), + Line::from(""), + Line::from(app.query_execution_copy()), + Line::from(app.export_execution_copy()), + ])) + .block(panel_block("Results", app.focus == FocusArea::Results)) + .wrap(Wrap { trim: true }); + + frame.render_widget(widget, area); + } + QueryRunState::Error => { + let error_message = app + .last_query_error + .as_ref() + .map(|error| error.message.as_str()) + .unwrap_or("Query failed."); + + let widget = Paragraph::new(Text::from(vec![ + Line::from("Query error"), + Line::from(""), + Line::from(error_message), + Line::from(""), + Line::from(app.query_execution_copy()), + ])) + .block(panel_block("Results", app.focus == FocusArea::Results)) + .wrap(Wrap { trim: true }); + + frame.render_widget(widget, area); + } + QueryRunState::Success => { + if let Some(rows_affected) = app + .last_query_response + .as_ref() + .and_then(|response| response.result.rows_affected) + { + let widget = Paragraph::new(Text::from(vec![ + Line::from("Command completed"), + Line::from(""), + Line::from(app.query_execution_copy()), + Line::from(""), + Line::from(format!("Rows affected: {rows_affected}")), + Line::from(app.export_execution_copy()), + ])) + .block(panel_block("Results", app.focus == FocusArea::Results)) + .wrap(Wrap { trim: true }); + + frame.render_widget(widget, area); + return; + } + + let columns = app.current_result_columns(); + let rows = app.current_result_rows(); + let (column_start, column_end) = app.visible_result_column_range(); + let visible_rows = rows + .iter() + .enumerate() + .skip(app.result_row_offset) + .take(RESULT_ROW_WINDOW) + .map(|(row_index, row)| { + let style = if row_index == app.selected_result_row { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default().fg(Color::Gray) + }; + + let visible_cells = row[column_start..column_end] + .iter() + .map(|value| Cell::from(value.clone())) + .collect::>(); + + Row::new(visible_cells).style(style) + }); + + let header = columns[column_start..column_end] + .iter() + .map(|column| Cell::from(column.clone())) + .collect::>(); + + let constraints = (column_start..column_end) + .map(|_| Constraint::Length(16)) + .collect::>(); + + let table = ratatui::widgets::Table::new( + visible_rows.collect::>(), + constraints, + ) + .header( + Row::new(header).style( + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + ) + .block(panel_block("Results", app.focus == FocusArea::Results)) + .column_spacing(1); + + frame.render_widget(table, area); + } + }, + } +} + +fn render_inspector(frame: &mut Frame, area: Rect, app: &App) { + let active = app.active_connection_record(); + let selected = app.selected_connection_record(); + let schema = active.selected_schema(); + let object = active.selected_object(); + let draft = app.current_query_draft(); + + let lines = match app.view { + WorkspaceView::Workspace => { + let mut lines = vec![ + Line::from("Workspace Context"), + Line::from(""), + Line::from(format!("Profile: {}", active.summary.profile_name)), + Line::from(format!("Schema: {}", schema.name)), + Line::from(format!("Schema state: {}", schema.state.label())), + Line::from(format!("Query draft: {}", draft.name)), + Line::from(format!("Execution: {}", app.query_run_state.label())), + Line::from(format!("Export: {}", app.export_run_state.label())), + ]; + + if let Some(object) = object { + lines.push(Line::from(format!("Object: {}", object.name))); + lines.push(Line::from(format!("Kind: {}", object.kind.label()))); + lines.push(Line::from(format!("Columns: {}", object.columns.len()))); + } + + match app.query_run_state { + QueryRunState::Success => { + lines.push(Line::from("")); + lines.push(Line::from("Result Detail")); + lines.push(Line::from(format!( + "Rows: {}", + app.current_result_rows().len() + ))); + lines.push(Line::from(format!( + "Selected row: {}", + app.selected_result_row + 1 + ))); + lines.push(Line::from(format!( + "Visible columns: {}-{}", + app.visible_result_column_range().0 + 1, + app.visible_result_column_range().1 + ))); + } + QueryRunState::Empty => { + lines.push(Line::from("")); + lines.push(Line::from("Result Detail")); + lines.push(Line::from("Query succeeded with zero rows.")); + } + QueryRunState::Error => { + lines.push(Line::from("")); + lines.push(Line::from("Error Detail")); + lines.push(Line::from( + app.last_query_error + .as_ref() + .map(|error| error.message.as_str()) + .unwrap_or("No structured error detail available."), + )); + } + QueryRunState::Idle | QueryRunState::Running => {} + } + + if matches!( + app.export_run_state, + ExportRunState::Running | ExportRunState::Success | ExportRunState::Error + ) { + lines.push(Line::from("")); + lines.push(Line::from("Export Detail")); + lines.push(Line::from(app.export_execution_copy())); + } + + if let Some(message) = active.failure_message.as_deref() { + lines.push(Line::from("")); + lines.push(Line::from("Connection Failure")); + lines.push(Line::from(message)); + } + + lines + } + WorkspaceView::Connections => { + let mut lines = vec![ + Line::from("Selected vs Current"), + Line::from(""), + Line::from(format!("Selected: {}", selected.summary.profile_name)), + Line::from(format!("Selected state: {}", selected.status.label())), + Line::from(format!("Current: {}", active.summary.profile_name)), + Line::from(format!("Current state: {}", active.status.label())), + Line::from(""), + ]; + + lines.extend(selected.details.iter().map(|line| Line::from(*line))); + + if let Some(message) = selected.failure_message.as_deref() { + lines.push(Line::from("")); + lines.push(Line::from("Visible failure")); + lines.push(Line::from(message)); + } + + lines + } + WorkspaceView::Help => vec![ + Line::from("Navigation Model"), + Line::from(""), + Line::from("- Enter runs from Editor focus"), + Line::from("- i edits; Esc exits edit mode"), + Line::from("- [ ] switches drafts or pages wide results"), + Line::from("- x/j exports the current result"), + Line::from("- ↑/↓ browses result rows"), + ], + }; + + let widget = Paragraph::new(Text::from(lines)) + .block(panel_block("Inspector", app.focus == FocusArea::Inspector)) + .wrap(Wrap { trim: true }); + + frame.render_widget(widget, area); +} + +fn render_status(frame: &mut Frame, area: Rect, app: &App) { + let active = app.active_connection_record(); + let schema = active.selected_schema(); + let draft = app.current_query_draft(); + + let banner = Line::from(vec![ + Span::styled("Surface State: ", Style::default().fg(Color::Gray)), + Span::styled( + app.surface_state.label(), + Style::default() + .fg(app.surface_state.accent()) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled("Execution: ", Style::default().fg(Color::Gray)), + Span::styled( + app.query_run_state.label(), + Style::default() + .fg(app.query_run_state.accent()) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled("Export: ", Style::default().fg(Color::Gray)), + Span::styled( + app.export_run_state.label(), + Style::default() + .fg(app.export_run_state.accent()) + .add_modifier(Modifier::BOLD), + ), + ]); + + let mut lines = vec![ + banner, + Line::from(""), + Line::from(format!("Draft: {}", draft.name)), + Line::from(app.query_execution_copy()), + Line::from(""), + Line::from(format!("Browser target: {} ({})", schema.name, schema.state.label())), + Line::from(active.browser_status_message()), + ]; + + if app.export_run_state != ExportRunState::Idle { + lines.insert(4, Line::from(app.export_execution_copy())); + } + + if let Some(message) = active.failure_message.as_deref() { + lines.push(Line::from("")); + lines.push(Line::from("Recovery path")); + lines.push(Line::from(message)); + } else if schema.state == SchemaState::Restricted { + lines.push(Line::from("")); + lines.push(Line::from("Schema failure")); + lines.push(Line::from(schema.message)); + } else if app.query_run_state == QueryRunState::Success + && app.current_result_columns().len() > RESULT_COLUMN_WINDOW + { + let (start, end) = app.visible_result_column_range(); + lines.push(Line::from("")); + lines.push(Line::from("Wide result handling")); + lines.push(Line::from(format!( + "Showing columns {}-{} of {}. Use [ and ] in Results focus to page.", + start + 1, + end, + app.current_result_columns().len() + ))); + } else if app.query_run_state == QueryRunState::Error { + lines.push(Line::from("")); + lines.push(Line::from("Failure path")); + lines.push(Line::from(app.query_execution_copy())); + } else if app.export_run_state == ExportRunState::Error { + lines.push(Line::from("")); + lines.push(Line::from("Export failure")); + lines.push(Line::from(app.export_execution_copy())); + } else if app.export_run_state == ExportRunState::Success { + lines.push(Line::from("")); + lines.push(Line::from("Export success")); + lines.push(Line::from(app.export_execution_copy())); + } + + let widget = Paragraph::new(Text::from(lines)) + .block(panel_block("Status & Activity", app.focus == FocusArea::Status)) + .wrap(Wrap { trim: true }); + + frame.render_widget(widget, area); +} + +fn panel_block(title: &str, focused: bool) -> Block<'static> { + let border_style = if focused { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + }; + + let title_style = if focused { + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Gray) + }; + + Block::default() + .title(Span::styled(title.to_string(), title_style)) + .borders(Borders::ALL) + .border_style(border_style) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn editor_brackets_switch_selected_query() { + let mut app = App::default(); + app.focus = FocusArea::Editor; + + app.handle_key(KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE)); + + assert_eq!(app.selected_query, 1); + assert!(app.activity_message.contains("ticket_export_preview.sql")); + } + + #[test] + fn enter_in_editor_starts_running_query() { + let mut app = App::default(); + app.focus = FocusArea::Editor; + + app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(app.query_run_state, QueryRunState::Running); + assert_eq!(app.surface_state, SurfaceState::Loading); + } + + #[test] + fn apply_query_result_sets_empty_state() { + let mut app = App::default(); + let request = QueryRequest::new( + app.query_drafts[2].sql.clone(), + QuerySource::Inline, + Vec::new(), + ) + .expect("query request should be valid"); + + app.apply_query_result( + request, + Ok(QueryResponse { + target: app.active_connection_record().summary.clone(), + source: db_app::QuerySourceSummary::from(&QuerySource::Inline), + result: db_app::QueryDataset { + columns: vec![String::from("id"), String::from("status")], + rows: Vec::new(), + rows_affected: None, + row_count: 0, + }, + }), + 95, + ); + + assert_eq!(app.query_run_state, QueryRunState::Empty); + assert_eq!(app.current_result_columns().len(), 2); + } + + #[test] + fn results_brackets_page_wide_columns() { + let mut app = App::default(); + app.focus = FocusArea::Results; + app.last_query_response = Some(QueryResponse { + target: app.active_connection_record().summary.clone(), + source: db_app::QuerySourceSummary::from(&QuerySource::Inline), + result: db_app::QueryDataset { + columns: vec![ + String::from("ticket_id"), + String::from("email"), + String::from("title"), + String::from("status"), + String::from("amount_cents"), + String::from("created_at"), + ], + rows: vec![vec![ + String::from("101"), + String::from("alice@example.com"), + String::from("login timeout"), + String::from("open"), + String::from("0"), + String::from("2026-03-20T10:00:00Z"), + ]], + rows_affected: None, + row_count: 1, + }, + }); + app.query_run_state = QueryRunState::Success; + + app.handle_key(KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE)); + + assert_eq!(app.result_col_offset, 1); + } + + #[test] + fn apply_export_result_sets_success_state() { + let mut app = App::default(); + app.apply_export_result( + Ok(ExportResponse { + target: app.active_connection_record().summary.clone(), + source: db_app::QuerySourceSummary::from(&QuerySource::Inline), + export: db_app::ExportSummary { + format: "csv", + output_path: String::from("/tmp/demo.csv"), + overwrite: true, + }, + row_count: 4, + }), + 12, + ); + + assert_eq!(app.export_run_state, ExportRunState::Success); + assert!(app.export_execution_copy().contains("/tmp/demo.csv")); + } + + #[test] + fn insert_mode_appends_query_text() { + let mut app = App::default(); + app.focus = FocusArea::Editor; + app.handle_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + app.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + + assert!(app.current_query_draft().sql.ends_with('x')); + assert_eq!(app.editor_mode, EditorMode::Insert); + } +} diff --git a/crates/db-app/Cargo.toml b/crates/db-app/Cargo.toml new file mode 100644 index 0000000..7ae83e0 --- /dev/null +++ b/crates/db-app/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "db-app" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +db-config.workspace = true +db-core.workspace = true +db-drivers.workspace = true +serde = { version = "1.0.228", features = ["derive"] } + +[dev-dependencies] +rusqlite.workspace = true +serde_json = "1.0.145" + +[lints] +workspace = true diff --git a/crates/db-app/src/lib.rs b/crates/db-app/src/lib.rs new file mode 100644 index 0000000..1a1696d --- /dev/null +++ b/crates/db-app/src/lib.rs @@ -0,0 +1,812 @@ +use db_config::ConnectionProfile; +use db_core::{ + ColumnSummary, CoreError, ExportFormat, ExportRequest, InspectRequest, InspectResult, + QueryRequest, QueryResult, QuerySource, SchemaSummary, TableSummary, +}; +use db_drivers::{DriverError, DriverRegistry}; +use serde::Serialize; +use std::{error::Error, fmt, fs}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AppOperation { + Connect, + Inspect, + Query, + Export, +} + +impl AppOperation { + pub fn as_str(self) -> &'static str { + match self { + Self::Connect => "connect", + Self::Inspect => "inspect", + Self::Query => "query", + Self::Export => "export", + } + } + + fn from_name(name: &'static str) -> Self { + match name { + "connect" => Self::Connect, + "inspect" => Self::Inspect, + "query" => Self::Query, + "export" => Self::Export, + other => panic!("unsupported app operation: {other}"), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum OperationState { + Running, + Success, + Empty, + Error, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct AppEvent { + pub operation: AppOperation, + pub state: OperationState, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl AppEvent { + pub fn running(operation: AppOperation) -> Self { + Self { + operation, + state: OperationState::Running, + data: None, + error: None, + } + } + + pub fn finished(operation: AppOperation, state: OperationState, data: T) -> Self { + Self { + operation, + state, + data: Some(data), + error: None, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct ConnectionSummary { + pub profile_name: String, + pub driver: String, + pub endpoint: String, + pub password_env_var: Option, +} + +impl From<&ConnectionProfile> for ConnectionSummary { + fn from(profile: &ConnectionProfile) -> Self { + Self { + profile_name: profile.name.clone(), + driver: profile.target.kind.as_str().to_string(), + endpoint: profile.target.redacted_endpoint(), + password_env_var: profile.password_env_var.clone(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct ConnectResponse { + pub target: ConnectionSummary, + pub status: &'static str, +} + +impl ConnectResponse { + pub fn operation(&self) -> AppOperation { + AppOperation::Connect + } + + pub fn state(&self) -> OperationState { + OperationState::Success + } + + pub fn into_event(self) -> AppEvent { + AppEvent::finished(self.operation(), self.state(), self) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct InspectScope { + pub schema: Option, + pub table: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct InspectResponse { + pub target: ConnectionSummary, + pub scope: InspectScope, + pub payload: InspectPayload, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", content = "items", rename_all = "snake_case")] +pub enum InspectPayload { + Schemas(Vec), + Tables(Vec), + Columns(Vec), +} + +impl InspectPayload { + pub fn is_empty(&self) -> bool { + match self { + Self::Schemas(items) => items.is_empty(), + Self::Tables(items) => items.is_empty(), + Self::Columns(items) => items.is_empty(), + } + } +} + +impl InspectResponse { + pub fn operation(&self) -> AppOperation { + AppOperation::Inspect + } + + pub fn state(&self) -> OperationState { + if self.payload.is_empty() { + OperationState::Empty + } else { + OperationState::Success + } + } + + pub fn into_event(self) -> AppEvent { + AppEvent::finished(self.operation(), self.state(), self) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct SchemaItem { + pub name: String, +} + +impl From for SchemaItem { + fn from(value: SchemaSummary) -> Self { + Self { name: value.name } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct TableItem { + pub schema: String, + pub name: String, + pub kind: String, +} + +impl From for TableItem { + fn from(value: TableSummary) -> Self { + Self { + schema: value.schema, + name: value.name, + kind: value.kind, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct ColumnItem { + pub name: String, + pub data_type: String, + pub nullable: bool, + pub primary_key: bool, +} + +impl From for ColumnItem { + fn from(value: ColumnSummary) -> Self { + Self { + name: value.name, + data_type: value.data_type, + nullable: value.nullable, + primary_key: value.primary_key, + } + } +} + +impl From for InspectPayload { + fn from(value: InspectResult) -> Self { + match value { + InspectResult::Schemas(items) => { + Self::Schemas(items.into_iter().map(SchemaItem::from).collect()) + } + InspectResult::Tables(items) => { + Self::Tables(items.into_iter().map(TableItem::from).collect()) + } + InspectResult::Columns(items) => { + Self::Columns(items.into_iter().map(ColumnItem::from).collect()) + } + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct QuerySourceSummary { + pub kind: &'static str, + pub label: &'static str, + pub path: Option, +} + +impl From<&QuerySource> for QuerySourceSummary { + fn from(value: &QuerySource) -> Self { + match value { + QuerySource::Inline => Self { + kind: "inline", + label: value.label(), + path: None, + }, + QuerySource::File(path) => Self { + kind: "file", + label: value.label(), + path: Some(path.display().to_string()), + }, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct QueryDataset { + pub columns: Vec, + pub rows: Vec>, + pub rows_affected: Option, + pub row_count: usize, +} + +impl QueryDataset { + pub fn is_empty(&self) -> bool { + self.rows_affected.is_none() && self.row_count == 0 + } +} + +impl From for QueryDataset { + fn from(value: QueryResult) -> Self { + Self { + row_count: value.rows.len(), + columns: value.columns, + rows: value.rows, + rows_affected: value.rows_affected, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct QueryResponse { + pub target: ConnectionSummary, + pub source: QuerySourceSummary, + pub result: QueryDataset, +} + +impl QueryResponse { + pub fn operation(&self) -> AppOperation { + AppOperation::Query + } + + pub fn state(&self) -> OperationState { + if self.result.is_empty() { + OperationState::Empty + } else { + OperationState::Success + } + } + + pub fn into_event(self) -> AppEvent { + AppEvent::finished(self.operation(), self.state(), self) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct ExportSummary { + pub format: &'static str, + pub output_path: String, + pub overwrite: bool, +} + +impl From<&ExportRequest> for ExportSummary { + fn from(value: &ExportRequest) -> Self { + Self { + format: export_format_name(value.format), + output_path: value.output_path.display().to_string(), + overwrite: value.overwrite, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct ExportResponse { + pub target: ConnectionSummary, + pub source: QuerySourceSummary, + pub export: ExportSummary, + pub row_count: usize, +} + +impl ExportResponse { + pub fn operation(&self) -> AppOperation { + AppOperation::Export + } + + pub fn state(&self) -> OperationState { + OperationState::Success + } + + pub fn into_event(self) -> AppEvent { + AppEvent::finished(self.operation(), self.state(), self) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AppErrorKind { + Validation, + Connection, + Authentication, + Inspect, + Query, + Export, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct AppError { + pub operation: &'static str, + pub kind: AppErrorKind, + pub message: String, + pub target: Option, +} + +impl AppError { + pub fn operation_kind(&self) -> AppOperation { + AppOperation::from_name(self.operation) + } + + pub fn state(&self) -> OperationState { + OperationState::Error + } + + pub fn into_event(self) -> AppEvent<()> { + AppEvent { + operation: self.operation_kind(), + state: self.state(), + data: None, + error: Some(self), + } + } +} + +impl fmt::Display for AppError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl Error for AppError {} + +pub fn connect(profile: &ConnectionProfile) -> Result { + validate_profile("connect", profile)?; + DriverRegistry::connect(&profile.target) + .map_err(|error| map_driver_error("connect", profile, error))?; + + Ok(ConnectResponse { + target: ConnectionSummary::from(profile), + status: "connected", + }) +} + +pub fn inspect( + profile: &ConnectionProfile, + request: &InspectRequest, +) -> Result { + validate_profile("inspect", profile)?; + request + .validate() + .map_err(|error| map_core_error("inspect", Some(profile), error))?; + + let payload = DriverRegistry::inspect(&profile.target, request) + .map(InspectPayload::from) + .map_err(|error| map_driver_error("inspect", profile, error))?; + + Ok(InspectResponse { + target: ConnectionSummary::from(profile), + scope: InspectScope { + schema: request.schema.clone(), + table: request.table.clone(), + }, + payload, + }) +} + +pub fn query(profile: &ConnectionProfile, request: &QueryRequest) -> Result { + validate_profile("query", profile)?; + request + .validate() + .map_err(|error| map_core_error("query", Some(profile), error))?; + + let result = DriverRegistry::query(&profile.target, request) + .map(QueryDataset::from) + .map_err(|error| map_driver_error("query", profile, error))?; + + Ok(QueryResponse { + target: ConnectionSummary::from(profile), + source: QuerySourceSummary::from(&request.source), + result, + }) +} + +pub fn export( + profile: &ConnectionProfile, + request: &QueryRequest, + export: &ExportRequest, +) -> Result { + validate_profile("export", profile)?; + request + .validate() + .map_err(|error| map_core_error("export", Some(profile), error))?; + export + .validate() + .map_err(|error| map_core_error("export", Some(profile), error))?; + validate_export_path(export).map_err(|message| AppError { + operation: "export", + kind: AppErrorKind::Export, + message, + target: Some(ConnectionSummary::from(profile)), + })?; + + let query_response = query(profile, request)?; + validate_export_result(&query_response.result).map_err(|message| AppError { + operation: "export", + kind: AppErrorKind::Export, + message, + target: Some(ConnectionSummary::from(profile)), + })?; + + let body = render_export_body(export.format, &query_response.result); + fs::write(&export.output_path, body).map_err(|error| AppError { + operation: "export", + kind: AppErrorKind::Export, + message: format!( + "failed to write export {}: {error}", + export.output_path.display() + ), + target: Some(ConnectionSummary::from(profile)), + })?; + + Ok(ExportResponse { + target: query_response.target, + source: query_response.source, + export: ExportSummary::from(export), + row_count: query_response.result.row_count, + }) +} + +pub fn validate_export_path(export: &ExportRequest) -> Result<(), String> { + if export.output_path.exists() && !export.overwrite { + return Err(format!( + "export request is invalid: output file {} already exists; pass --overwrite to replace it", + export.output_path.display() + )); + } + + if export.output_path.is_dir() { + return Err(format!( + "export request is invalid: output path {} is a directory; provide a file path", + export.output_path.display() + )); + } + + if let Some(parent) = export.output_path.parent() { + if !parent.as_os_str().is_empty() && !parent.exists() { + return Err(format!( + "export request is invalid: parent directory {} does not exist; create it first or choose another output path", + parent.display() + )); + } + } + + Ok(()) +} + +pub fn validate_export_result(result: &QueryDataset) -> Result<(), String> { + if result.rows_affected.is_some() { + return Err(String::from( + "export requires a row-returning query; the provided SQL completed without a result set", + )); + } + + Ok(()) +} + +pub fn render_export_body(format: ExportFormat, result: &QueryDataset) -> String { + match format { + ExportFormat::Csv => render_csv(result), + ExportFormat::Json => render_json(result), + } +} + +fn validate_profile(operation: &'static str, profile: &ConnectionProfile) -> Result<(), AppError> { + profile + .validate() + .map_err(|error| map_core_error(operation, Some(profile), error)) +} + +fn map_core_error( + operation: &'static str, + profile: Option<&ConnectionProfile>, + error: CoreError, +) -> AppError { + AppError { + operation, + kind: AppErrorKind::Validation, + message: error.to_string(), + target: profile.map(ConnectionSummary::from), + } +} + +fn map_driver_error( + operation: &'static str, + profile: &ConnectionProfile, + error: DriverError, +) -> AppError { + let kind = match &error { + DriverError::NotImplemented(_) => AppErrorKind::Validation, + DriverError::Connection(_) => AppErrorKind::Connection, + DriverError::Authentication(_) => AppErrorKind::Authentication, + DriverError::Inspect(_) => AppErrorKind::Inspect, + DriverError::Query(_) => AppErrorKind::Query, + }; + + AppError { + operation, + kind, + message: error.to_string(), + target: Some(ConnectionSummary::from(profile)), + } +} + +fn export_format_name(format: ExportFormat) -> &'static str { + match format { + ExportFormat::Csv => "csv", + ExportFormat::Json => "json", + } +} + +fn render_csv(result: &QueryDataset) -> String { + let mut lines = Vec::new(); + lines.push(result.columns.join(",")); + lines.extend(result.rows.iter().map(|row| { + row.iter() + .map(|value| escape_csv(value)) + .collect::>() + .join(",") + })); + + if lines.is_empty() { + String::new() + } else { + format!("{}\n", lines.join("\n")) + } +} + +fn render_json(result: &QueryDataset) -> String { + let rows = result + .rows + .iter() + .map(|row| { + let fields = result + .columns + .iter() + .zip(row.iter()) + .map(|(column, value)| { + format!("\"{}\":\"{}\"", escape_json(column), escape_json(value)) + }) + .collect::>() + .join(","); + format!(" {{{fields}}}") + }) + .collect::>() + .join(",\n"); + + if rows.is_empty() { + String::from("[]\n") + } else { + format!("[\n{rows}\n]\n") + } +} + +fn escape_csv(value: &str) -> String { + if value.contains(',') || value.contains('"') || value.contains('\n') { + format!("\"{}\"", value.replace('"', "\"\"")) + } else { + value.to_string() + } +} + +fn escape_json(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use db_core::{ConnectionTarget, ConnectionTransport, DatabaseKind}; + use rusqlite::Connection as SqliteConnection; + use std::time::{SystemTime, UNIX_EPOCH}; + use std::{env, fs}; + + #[test] + fn sqlite_query_returns_structured_result() { + let path = temp_sqlite_db_path("dbtool-app-query"); + let connection = + SqliteConnection::open(&path).expect("temporary sqlite database should open"); + connection + .execute_batch(include_str!("../../../examples/fixtures/sqlite/init.sql")) + .expect("sqlite fixture should seed"); + drop(connection); + + let target = ConnectionTarget::new( + DatabaseKind::Sqlite, + ConnectionTransport::File { path: path.clone() }, + None, + None, + ) + .expect("sqlite target should be valid"); + let profile = + ConnectionProfile::new("qa-demo", target, None).expect("profile should be valid"); + let request = QueryRequest::new( + String::from("SELECT COUNT(*) AS count FROM tickets WHERE status = ?1"), + QuerySource::Inline, + vec![String::from("open")], + ) + .expect("request should be valid"); + + let response = query(&profile, &request).expect("query should succeed"); + + assert_eq!(response.target.driver, "sqlite"); + assert_eq!(response.source.kind, "inline"); + assert_eq!(response.result.columns, vec![String::from("count")]); + assert_eq!(response.result.rows, vec![vec![String::from("3")]]); + assert_eq!(response.result.row_count, 1); + + fs::remove_file(path).expect("temporary sqlite database should be removed"); + } + + #[test] + fn export_body_json_escapes_quotes_and_newlines() { + let body = render_export_body( + ExportFormat::Json, + &QueryDataset { + columns: vec![String::from("title"), String::from("notes")], + rows: vec![vec![ + String::from("csv export mismatch"), + String::from("line one\n\"line two\""), + ]], + rows_affected: None, + row_count: 1, + }, + ); + + assert_eq!( + body, + "[\n {\"title\":\"csv export mismatch\",\"notes\":\"line one\\n\\\"line two\\\"\"}\n]\n" + ); + } + + #[test] + fn inspect_response_uses_empty_state_for_empty_payload() { + let response = InspectResponse { + target: connection_summary(), + scope: InspectScope { + schema: Some(String::from("main")), + table: None, + }, + payload: InspectPayload::Tables(Vec::new()), + }; + + assert_eq!(response.state(), OperationState::Empty); + } + + #[test] + fn query_response_distinguishes_empty_rows_from_command_results() { + let empty = QueryResponse { + target: connection_summary(), + source: QuerySourceSummary { + kind: "inline", + label: "inline SQL", + path: None, + }, + result: QueryDataset { + columns: vec![String::from("count")], + rows: Vec::new(), + rows_affected: None, + row_count: 0, + }, + }; + let command = QueryResponse { + target: connection_summary(), + source: QuerySourceSummary { + kind: "inline", + label: "inline SQL", + path: None, + }, + result: QueryDataset { + columns: Vec::new(), + rows: Vec::new(), + rows_affected: Some(2), + row_count: 0, + }, + }; + + assert_eq!(empty.state(), OperationState::Empty); + assert_eq!(command.state(), OperationState::Success); + } + + #[test] + fn query_response_event_serializes_operation_and_state() { + let event = QueryResponse { + target: connection_summary(), + source: QuerySourceSummary { + kind: "file", + label: "SQL file", + path: Some(String::from("queries/open.sql")), + }, + result: QueryDataset { + columns: vec![String::from("count")], + rows: vec![vec![String::from("3")]], + rows_affected: None, + row_count: 1, + }, + } + .into_event(); + + let json = serde_json::to_value(&event).expect("event should serialize"); + + assert_eq!(json["operation"], "query"); + assert_eq!(json["state"], "success"); + assert_eq!(json["data"]["source"]["kind"], "file"); + assert_eq!(json["data"]["result"]["rows"][0][0], "3"); + assert!(json.get("error").is_none()); + } + + #[test] + fn app_error_event_serializes_error_state() { + let event = AppError { + operation: "inspect", + kind: AppErrorKind::Inspect, + message: String::from("schema lookup failed"), + target: Some(connection_summary()), + } + .into_event(); + + let json = serde_json::to_value(&event).expect("error event should serialize"); + + assert_eq!(json["operation"], "inspect"); + assert_eq!(json["state"], "error"); + assert_eq!(json["error"]["kind"], "inspect"); + assert_eq!(json["error"]["message"], "schema lookup failed"); + assert!(json.get("data").is_none()); + } + + fn connection_summary() -> ConnectionSummary { + ConnectionSummary { + profile_name: String::from("qa-demo"), + driver: String::from("sqlite"), + endpoint: String::from("sqlite:///tmp/qa-demo.sqlite"), + password_env_var: None, + } + } + + fn temp_sqlite_db_path(prefix: &str) -> std::path::PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos(); + env::temp_dir().join(format!("{prefix}-{unique}.sqlite")) + } +}