feat: add dbtool tui live query workspace
Some checks failed
release-smoke / macos-13 / x86_64-apple-darwin (push) Has been cancelled
release-smoke / ubuntu-latest / x86_64-unknown-linux-gnu (push) Has been cancelled
release-smoke / windows-latest / x86_64-pc-windows-msvc (push) Has been cancelled

- add shared db-app layer for connect/inspect/query/export events
- add dbtool-tui workspace with sqlite-local live flow and QA docs
- include host-validated acceptance updates for CMP-36

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Paperclip CTO
2026-03-27 17:17:20 +00:00
parent 7424491944
commit 19aeb7784b
12 changed files with 5006 additions and 4 deletions

424
Cargo.lock generated
View File

@@ -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"

View File

@@ -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" }

View File

@@ -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<T>` 作为 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 结构

View File

@@ -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 路径

240
TUI_BACKEND_CONTRACT.md Normal file
View File

@@ -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<String>`
- `table: Option<String>`
- 约束:
- `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<T>`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<T>` / `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 内复制数据库语义。
- 若未来引入本地持久化(例如连接历史),应先新增独立文档定义存储边界,再进入实现。

View File

@@ -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` 建立产物路径

121
TUI_TEST_STRATEGY.md Normal file
View File

@@ -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 路径

20
apps/tui/Cargo.toml Normal file
View File

@@ -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

114
apps/tui/README.md Normal file
View File

@@ -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`

3053
apps/tui/src/main.rs Normal file

File diff suppressed because it is too large Load Diff

19
crates/db-app/Cargo.toml Normal file
View File

@@ -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

812
crates/db-app/src/lib.rs Normal file
View File

@@ -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<T> {
pub operation: AppOperation,
pub state: OperationState,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<T>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<AppError>,
}
impl<T> AppEvent<T> {
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<String>,
}
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<Self> {
AppEvent::finished(self.operation(), self.state(), self)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct InspectScope {
pub schema: Option<String>,
pub table: Option<String>,
}
#[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<SchemaItem>),
Tables(Vec<TableItem>),
Columns(Vec<ColumnItem>),
}
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<Self> {
AppEvent::finished(self.operation(), self.state(), self)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct SchemaItem {
pub name: String,
}
impl From<SchemaSummary> 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<TableSummary> 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<ColumnSummary> 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<InspectResult> 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<String>,
}
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<String>,
pub rows: Vec<Vec<String>>,
pub rows_affected: Option<u64>,
pub row_count: usize,
}
impl QueryDataset {
pub fn is_empty(&self) -> bool {
self.rows_affected.is_none() && self.row_count == 0
}
}
impl From<QueryResult> 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<Self> {
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<Self> {
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<ConnectionSummary>,
}
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<ConnectResponse, AppError> {
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<InspectResponse, AppError> {
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<QueryResponse, AppError> {
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<ExportResponse, AppError> {
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::<Vec<_>>()
.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::<Vec<_>>()
.join(",");
format!(" {{{fields}}}")
})
.collect::<Vec<_>>()
.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"))
}
}