diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..def90fd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,206 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +`fb-cli` is a command-line client for Firebolt database written in Rust. It supports both single-query execution and an interactive REPL mode with query history and dynamic parameter configuration. + +**Note**: This project has been moved to https://github.com/firebolt-db/fb-cli + +## Build and Development Commands + +### Building +```bash +cargo build --release +``` + +### Testing +```bash +# Run all tests +cargo test + +# Run specific test +cargo test test_name + +# Run tests with output +cargo test -- --nocapture + +# Integration tests only +cargo test --test cli +``` + +### Installation +```bash +cargo install --path . --locked +``` + +### Formatting +```bash +cargo fmt +``` + +### Linting +```bash +cargo clippy +``` + +## Architecture + +### Module Structure + +- **main.rs**: Entry point; routes to headless (single query / pipe) or interactive TUI mode +- **tui/mod.rs**: Interactive TUI built with ratatui + tui-textarea; event loop, key handling, slash commands, completion, history +- **args.rs**: Command-line argument parsing with gumdrop, URL generation, and default config management +- **context.rs**: Application state container holding Args and ServiceAccountToken +- **query.rs**: HTTP query execution, set/unset parameter handling, and SQL query splitting using Pest parser +- **auth.rs**: OAuth2 service account authentication with token caching +- **utils.rs**: File paths (~/.firebolt/), spinner animation, and time formatting utilities +- **sql.pest**: Pest grammar for parsing SQL queries (handles strings, comments, semicolons) + +### Key Design Patterns + +**Context Management**: The `Context` struct is passed mutably through the application, allowing runtime updates to Args and URL as users `set` and `unset` parameters in REPL mode. + +**Query Splitting**: SQL queries are parsed using a Pest grammar (sql.pest) that correctly handles: +- String literals (single quotes with escape sequences) +- E-strings (PostgreSQL-style escaped strings) +- Raw strings ($$...$$) +- Quoted identifiers (double quotes) +- Line comments (--) and nested block comments (/* /* */ */) +- Semicolons as query terminators (but not within strings/comments) + +**Authentication Flow**: +1. Service Account tokens are cached in `~/.firebolt/fb_sa_token` +2. Tokens are valid for 30 minutes and automatically reused if valid +3. JWT can be loaded from `~/.firebolt/jwt` with `--jwt-from-file` +4. Bearer tokens can be passed directly with `--bearer` + +**Configuration Persistence**: Defaults are stored in `~/.firebolt/fb_config` as YAML. When `--update-defaults` is used, current arguments (excluding update_defaults itself) are saved and merged with future invocations. + +**Dynamic Parameter Updates**: The REPL supports `set key=value` and `unset key` commands to modify query parameters at runtime without restarting the CLI. Server can also update parameters via `firebolt-update-parameters` and `firebolt-remove-parameters` headers. + +### HTTP Protocol + +- Uses reqwest with HTTP/2 keep-alive (3600s timeout, 60s intervals) +- Sends queries as POST body to constructed URL with query parameters +- Headers: `user-agent`, `Firebolt-Protocol-Version: 2.3`, `authorization` (Bearer token) +- Handles special response headers: `firebolt-update-parameters`, `firebolt-remove-parameters`, `firebolt-update-endpoint` + +### Data Type Handling in JSONLines_Compact Format + +**Firebolt Serialization Patterns:** + +The `JSONLines_Compact` format uses type-specific JSON serialization to preserve precision and avoid limitations of JSON numbers: + +| Firebolt Type | JSON Representation | Example | +|---------------|---------------------|---------| +| INT | JSON number | `42` | +| BIGINT | JSON **string** | `"9223372036854775807"` | +| NUMERIC/DECIMAL | JSON **string** | `"1.23"` | +| DOUBLE/REAL | JSON number | `3.14` | +| TEXT | JSON string | `"text"` | +| DATE | JSON string (ISO) | `"2026-02-06"` | +| TIMESTAMP | JSON string | `"2026-02-06 15:35:34+00"` | +| BYTEA | JSON string (hex) | `"\\x48656c6c6f"` | +| GEOGRAPHY | JSON string (WKB hex) | `"0101000020E6..."` | +| ARRAY | JSON array | `[1,2,3]` | +| BOOLEAN | JSON boolean | `true` | +| NULL | JSON `null` | `null` | + +**Why certain types are JSON strings:** +- **BIGINT/NUMERIC**: JavaScript/JSON numbers are 64-bit floats with ~53 bits precision. BIGINT (64-bit int) and NUMERIC (38 digits) need strings to avoid precision loss. +- **DATE/TIMESTAMP**: ISO format strings are portable and timezone-aware +- **BYTEA**: Binary data encoded as hex strings (e.g., `\x48656c6c6f` = "Hello") +- **GEOGRAPHY**: Spatial data in WKB (Well-Known Binary) format encoded as hex strings + +**Client-Side Rendering:** + +The `format_value()` function renders these types for display: +- Numbers displayed via `.to_string()` (e.g., `42`, `3.14`) +- Strings displayed as-is without quotes (BIGINT `"9223372036854775807"` → displays as `9223372036854775807`) +- NULL rendered as SQL-style `"NULL"` string in tables, empty string in CSV +- Arrays/Objects JSON-serialized with truncation at 1000 characters + +The `column_type` field from the server is currently unused but available for future type-aware formatting. + +**Server-Side Formats:** + +Formats like `PSQL`, `TabSeparatedWithNames`, etc. bypass client parsing entirely and are printed raw to stdout. + +### URL Construction + +URLs are built from: +- Protocol: `http` for localhost, `https` otherwise +- Host: from `--host` or defaults +- Database: from `--database` or `--extra database=...` +- Format: from `--format` or `--extra format=...` +- Extra parameters: from `--extra` (URL-encoded once during normalize_extras) +- Query label: from `--label` +- Advanced mode: automatically added for non-localhost + +### Output Format Options with Client Prefix + +fb-cli uses a single `--format` option with a prefix notation to distinguish between client-side and server-side rendering: + +**Client-Side Rendering** (prefix with `client:`): +- Values: `client:auto`, `client:vertical`, `client:horizontal` +- Behavior: Client requests `JSONLines_Compact` and renders it with formatting +- Supports: Pretty tables, NULL coloring, csvlens viewer integration +- Default in interactive mode: `client:auto` + +**Client display modes:** +- `client:auto` - Smart switching: horizontal for narrow tables, vertical for wide tables +- `client:horizontal` - Force horizontal table with column headers +- `client:vertical` - Force vertical two-column layout (column | value) + +**Server-Side Rendering** (no prefix): +- Values: `PSQL`, `TabSeparatedWithNames`, `JSON`, `CSV`, `JSONLines_Compact`, etc. +- Behavior: Server renders output in this format, client prints raw output +- Default in non-interactive mode: `PSQL` + +**Detection:** +- If `--format` starts with `client:`: Use client-side rendering +- Otherwise: Use server-side rendering + +**Default Behavior:** +- Interactive sessions (TTY): `--format client:auto` (client-side pretty tables) +- Non-interactive (piped/redirected): `--format PSQL` (server-side, backwards compatible) +- Can be overridden with explicit `--format` flag + +**Changing format at runtime:** +- Command-line: `--format client:auto` or `--format JSON` +- SQL-style: `set format = client:vertical;` or `set format = CSV;` +- Reset: `unset format;` (resets to default based on interactive mode) + +**Config persistence:** +- Saved in `~/.firebolt/fb_config` +- Format can be persisted with `--update-defaults` +- Clear which mode: `client:` prefix means client-side, no prefix means server-side + +**csvlens Integration:** +- Only works with client-side formats (`client:*`) +- Automatically checks and displays error if server-side format used + +### Firebolt Core vs Standard + +- **Core mode** (`--core`): Connects to Firebolt Core at `localhost:3473`, database `firebolt`, format `PSQL`, no JWT +- **Standard mode** (default): Connects to `localhost:8123` (or 9123 with JWT), database `local_dev_db`, format `PSQL` + +## Testing Considerations + +- Unit tests are in-module (`#[cfg(test)] mod tests`) +- Integration tests in `tests/cli.rs` use `CARGO_BIN_EXE_fb` to test the compiled binary +- SQL parsing tests extensively cover edge cases: nested comments, strings with semicolons, escape sequences +- Auth tests are minimal due to external service dependencies + +## Important Behavioral Details + +- **URL encoding**: Parameters are encoded once during `normalize_extras(encode: true)`. Subsequent calls with `encode: false` prevent double-encoding. +- **TUI multi-line**: Press Shift+Enter or Alt+Enter to insert a newline. Queries must end with semicolon. +- **Ctrl+C in TUI**: Cancels an in-flight query; does not exit +- **Ctrl+D in TUI**: Exits +- **Ctrl+V in TUI**: Opens csvlens viewer for the last result directly +- **Alt+E in TUI**: Opens current query in $EDITOR +- **Spinner**: Shown during query execution for client-side formats only +- **History**: Saved to `~/.firebolt/fb_history` (max 10,000 entries), supports Ctrl+R search diff --git a/Cargo.lock b/Cargo.lock index 3432a9e..7221ead 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,1740 +3,3529 @@ version = 4 [[package]] -name = "addr2line" -version = "0.21.0" +name = "ahash" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ - "gimli", + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", ] -[[package]] -name = "adler" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" - [[package]] name = "aho-corasick" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] [[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.1.0" +name = "allocator-api2" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] -name = "backtrace" -version = "0.3.69" +name = "android_system_properties" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ - "addr2line", - "cc", - "cfg-if", "libc", - "miniz_oxide", - "object", - "rustc-demangle", ] [[package]] -name = "base64" -version = "0.21.5" +name = "ansi-to-tui" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" +checksum = "67555e1f1ece39d737e28c8a017721287753af3f93225e4a445b29ccb0f5912c" +dependencies = [ + "nom 7.1.3", + "ratatui", + "simdutf8", + "smallvec", + "thiserror 1.0.69", +] [[package]] -name = "bitflags" -version = "1.3.2" +name = "anstream" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] [[package]] -name = "bitflags" -version = "2.4.1" +name = "anstyle" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] -name = "block-buffer" -version = "0.10.4" +name = "anstyle-parse" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ - "generic-array", + "utf8parse", ] [[package]] -name = "bumpalo" -version = "3.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" - -[[package]] -name = "bytes" -version = "1.5.0" +name = "anstyle-query" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] [[package]] -name = "cc" -version = "1.0.83" +name = "anstyle-wincon" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ - "libc", + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", ] [[package]] -name = "cfg-if" -version = "1.0.0" +name = "anyhow" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] -name = "clipboard-win" -version = "4.5.0" +name = "arboard" +version = "3.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7191c27c2357d9b7ef96baac1773290d4ca63b24205b82a3fd8a0637afcf0362" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" dependencies = [ - "error-code", - "str-buf", - "winapi", + "clipboard-win", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", ] [[package]] -name = "core-foundation" -version = "0.9.3" +name = "arrow" +version = "56.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" +checksum = "6e833808ff2d94ed40d9379848a950d995043c7fb3e81a30b383f4c6033821cc" dependencies = [ - "core-foundation-sys", - "libc", + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-csv", + "arrow-data", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", ] [[package]] -name = "core-foundation-sys" -version = "0.8.4" +name = "arrow-arith" +version = "56.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" +checksum = "ad08897b81588f60ba983e3ca39bda2b179bdd84dced378e7df81a5313802ef8" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num", +] [[package]] -name = "cpufeatures" -version = "0.2.17" +name = "arrow-array" +version = "56.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "8548ca7c070d8db9ce7aa43f37393e4bfcf3f2d3681df278490772fd1673d08d" dependencies = [ - "libc", + "ahash", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown 0.16.1", + "num", ] [[package]] -name = "crypto-common" -version = "0.1.6" +name = "arrow-buffer" +version = "56.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "e003216336f70446457e280807a73899dd822feaf02087d31febca1363e2fccc" dependencies = [ - "generic-array", - "typenum", + "bytes", + "half", + "num", ] [[package]] -name = "digest" -version = "0.10.7" +name = "arrow-cast" +version = "56.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "919418a0681298d3a77d1a315f625916cb5678ad0d74b9c60108eb15fd083023" dependencies = [ - "block-buffer", - "crypto-common", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "atoi", + "base64", + "chrono", + "half", + "lexical-core", + "num", + "ryu", ] [[package]] -name = "dirs" -version = "5.0.1" +name = "arrow-csv" +version = "56.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +checksum = "bfa9bf02705b5cf762b6f764c65f04ae9082c7cfc4e96e0c33548ee3f67012eb" dependencies = [ - "dirs-sys", + "arrow-array", + "arrow-cast", + "arrow-schema", + "chrono", + "csv", + "csv-core", + "regex", ] [[package]] -name = "dirs-sys" -version = "0.4.1" +name = "arrow-data" +version = "56.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +checksum = "a5c64fff1d142f833d78897a772f2e5b55b36cb3e6320376f0961ab0db7bd6d0" dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys", + "arrow-buffer", + "arrow-schema", + "half", + "num", ] [[package]] -name = "encoding_rs" -version = "0.8.33" +name = "arrow-ord" +version = "56.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1" +checksum = "3c8f82583eb4f8d84d4ee55fd1cb306720cddead7596edce95b50ee418edf66f" dependencies = [ - "cfg-if", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", ] [[package]] -name = "endian-type" -version = "0.1.2" +name = "arrow-row" +version = "56.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" +checksum = "9d07ba24522229d9085031df6b94605e0f4b26e099fb7cdeec37abd941a73753" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] [[package]] -name = "equivalent" -version = "1.0.1" +name = "arrow-schema" +version = "56.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +checksum = "b3aa9e59c611ebc291c28582077ef25c97f1975383f1479b12f3b9ffee2ffabe" [[package]] -name = "errno" -version = "0.3.5" +name = "arrow-select" +version = "56.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3e13f66a2f95e32a39eaa81f6b95d42878ca0e1db0c7543723dfe12557e860" +checksum = "8c41dbbd1e97bfcaee4fcb30e29105fb2c75e4d82ae4de70b792a5d3f66b2e7a" dependencies = [ - "libc", - "windows-sys", + "ahash", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num", ] [[package]] -name = "error-code" -version = "2.3.1" +name = "arrow-string" +version = "56.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64f18991e7bf11e7ffee451b5318b5c1a73c52d0d0ada6e5a3017c8c1ced6a21" +checksum = "53f5183c150fbc619eede22b861ea7c0eebed8eaac0333eaa7f6da5205fd504d" dependencies = [ - "libc", - "str-buf", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num", + "regex", + "regex-syntax", ] [[package]] -name = "fastrand" -version = "2.0.1" +name = "atoi" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" - -[[package]] -name = "fb" -version = "0.2.3" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" dependencies = [ - "dirs", - "gumdrop", - "once_cell", - "openssl", - "pest", - "pest_derive", - "regex", - "reqwest", - "rustyline", - "serde", - "serde_json", - "serde_yaml", - "tokio", - "tokio-util", - "toml", - "urlencoding", + "num-traits", ] [[package]] -name = "fd-lock" -version = "3.0.13" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef033ed5e9bad94e55838ca0ca906db0e043f517adda0c8b79c7a8c66c93c1b5" -dependencies = [ - "cfg-if", - "rustix", - "windows-sys", -] +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "fnv" -version = "1.0.7" +name = "autocfg" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "foreign-types" -version = "0.3.2" +name = "base64" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "foreign-types-shared" -version = "0.1.1" +name = "bitflags" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] -name = "form_urlencoded" -version = "1.2.0" +name = "block-buffer" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "percent-encoding", + "generic-array", ] [[package]] -name = "futures-channel" -version = "0.3.29" +name = "bumpalo" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff4dd66668b557604244583e3e1e1eada8c5c2e96a6d0d6653ede395b78bbacb" -dependencies = [ - "futures-core", -] +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] -name = "futures-core" -version = "0.3.29" +name = "bytecount" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb1d22c66e66d9d72e1758f0bd7d4fd0bee04cad842ee34587d68c07e45d088c" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] -name = "futures-sink" -version = "0.3.29" +name = "bytes" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e36d3378ee38c2a36ad710c5d30c2911d752cb941c00c72dbabfb786a7970817" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] -name = "futures-task" -version = "0.3.29" +name = "cassowary" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd193069b0ddadc69c46389b740bbccdd97203899b48d09c5f7969591d6bae2" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" [[package]] -name = "futures-util" -version = "0.3.29" +name = "castaway" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a19526d624e703a3179b3d322efec918b6246ea0fa51d41124525f00f1cc8104" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "pin-utils", + "rustversion", ] [[package]] -name = "generic-array" -version = "0.14.7" +name = "cc" +version = "1.2.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" dependencies = [ - "typenum", - "version_check", + "find-msvc-tools", + "shlex", ] [[package]] -name = "getrandom" -version = "0.2.10" +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ - "cfg-if", - "libc", - "wasi", + "iana-time-zone", + "num-traits", + "windows-link", ] [[package]] -name = "gimli" -version = "0.28.0" +name = "clap" +version = "4.5.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0" +checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +dependencies = [ + "clap_builder", + "clap_derive", +] [[package]] -name = "gumdrop" -version = "0.8.1" +name = "clap_builder" +version = "4.5.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bc700f989d2f6f0248546222d9b4258f5b02a171a431f8285a81c08142629e3" +checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" dependencies = [ - "gumdrop_derive", + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size 0.4.3", ] [[package]] -name = "gumdrop_derive" -version = "0.8.1" +name = "clap_derive" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "729f9bd3449d77e7831a18abfb7ba2f99ee813dfd15b8c2167c9a54ba20aa99d" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ + "heck", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.114", ] [[package]] -name = "h2" -version = "0.4.10" +name = "clap_lex" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] -name = "hashbrown" -version = "0.14.2" +name = "clipboard-win" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93e7192158dbcda357bdec5fb5788eebf8bbac027f3f33e719d29135ae84156" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] [[package]] -name = "hermit-abi" -version = "0.3.3" +name = "colorchoice" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] -name = "home" -version = "0.5.5" +name = "compact_str" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" dependencies = [ - "windows-sys", + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", ] [[package]] -name = "http" -version = "1.1.0" +name = "const-random" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" dependencies = [ - "bytes", - "fnv", - "itoa", + "const-random-macro", ] [[package]] -name = "http-body" -version = "1.0.0" +name = "const-random-macro" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" dependencies = [ - "bytes", - "http", + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", ] [[package]] -name = "http-body-util" -version = "0.1.1" +name = "core-foundation" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0475f8b2ac86659c21b64320d5d653f9efe42acd2a4e560073ec61a155a34f1d" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", + "core-foundation-sys", + "libc", ] [[package]] -name = "httparse" -version = "1.8.0" +name = "core-foundation-sys" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "hyper" -version = "1.2.0" +name = "cpufeatures" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186548d73ac615b32a73aafe38fb4f56c0d340e110e5a200bcadbaf2e199263a" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "h2", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", + "libc", ] [[package]] -name = "hyper-tls" -version = "0.6.0" +name = "crossterm" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "filedescriptor", + "mio", + "parking_lot", + "rustix 0.38.44", + "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 = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "csvlens" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "721f1b47a845e16645f6ac0fac2227e04a7896a484116f9d831305ceb4d07a7c" +dependencies = [ + "ansi-to-tui", + "anyhow", + "arboard", + "arrow", + "clap", + "crossterm", + "csv", + "qsv-sniffer", + "ratatui", + "regex", + "sorted-vec", + "tempfile", + "terminal-colorsaurus", + "thiserror 2.0.18", + "tui-input", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[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 = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "fast-float2" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fb" +version = "0.2.3" dependencies = [ "bytes", + "crossterm", + "csvlens", + "dirs", + "gumdrop", "http-body-util", "hyper", "hyper-util", - "native-tls", + "nucleo-matcher", + "once_cell", + "openssl", + "pest", + "pest_derive", + "ratatui", + "regex", + "reqwest", + "serde", + "serde_json", + "serde_yaml", + "sqlformat", + "terminal_size 0.3.0", "tokio", - "tokio-native-tls", - "tower-service", + "tokio-util", + "toml", + "tui-textarea", + "urlencoding", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.3", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gumdrop" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bc700f989d2f6f0248546222d9b4258f5b02a171a431f8285a81c08142629e3" +dependencies = [ + "gumdrop_derive", +] + +[[package]] +name = "gumdrop_derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "729f9bd3449d77e7831a18abfb7ba2f99ee813dfd15b8c2167c9a54ba20aa99d" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[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.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[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.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-src" +version = "300.5.5+3.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "qsv-dateparser" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6030a42cfbad8f656c7c16b027e0957d85dc0b43365a88d830834de582d7a603" +dependencies = [ + "anyhow", + "chrono", + "fast-float2", + "regex", +] + +[[package]] +name = "qsv-sniffer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b25b79fc637d5ec0a9d72612207f48676b39c8b5c48ab32cfa0d47915fd664a" +dependencies = [ + "bitflags", + "bytecount", + "csv", + "csv-core", + "hashbrown 0.15.5", + "memchr", + "qsv-dateparser", + "regex", + "simdutf8", + "tabwriter", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "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", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", ] [[package]] -name = "hyper-util" -version = "0.1.3" +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "reqwest" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ + "base64", "bytes", - "futures-channel", - "futures-util", + "encoding_rs", + "futures-core", + "h2", "http", "http-body", + "http-body-util", "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", "pin-project-lite", - "socket2", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", "tokio", + "tokio-native-tls", "tower", + "tower-http", "tower-service", - "tracing", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[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 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[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.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +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 = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", ] [[package]] -name = "idna" -version = "0.4.0" +name = "sorted-vec" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f58d7b0190c7f12df7e8be6b79767a0836059159811b869d5ab55721fe14d0" + +[[package]] +name = "sqlformat" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +checksum = "0705994df478b895f05b8e290e0d46e53187b26f8d889d37b2a0881234922d94" dependencies = [ - "unicode-bidi", - "unicode-normalization", + "unicode_categories", + "winnow", ] [[package]] -name = "indexmap" -version = "2.1.0" +name = "stable_deref_trait" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" -dependencies = [ - "equivalent", - "hashbrown", -] +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] -name = "ipnet" -version = "2.9.0" +name = "static_assertions" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] -name = "itoa" -version = "1.0.9" +name = "strsim" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] -name = "js-sys" -version = "0.3.65" +name = "strum" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54c0c35952f67de54bb584e9fd912b3023117cbafc0a77d8f3dee1fb5f572fe8" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "wasm-bindgen", + "strum_macros", ] [[package]] -name = "lazy_static" -version = "1.4.0" +name = "strum_macros" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.114", +] [[package]] -name = "libc" -version = "0.2.171" +name = "subtle" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] -name = "linux-raw-sys" -version = "0.4.10" +name = "syn" +version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da2479e8c062e40bf0066ffa0bc823de0a9368974af99c9f6df941d2c231e03f" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] [[package]] -name = "lock_api" -version = "0.4.11" +name = "syn" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ - "autocfg", - "scopeguard", + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] -name = "log" -version = "0.4.20" +name = "sync_wrapper" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] -name = "memchr" -version = "2.6.4" +name = "synstructure" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] [[package]] -name = "mime" -version = "0.3.17" +name = "system-configuration" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] [[package]] -name = "miniz_oxide" -version = "0.7.1" +name = "system-configuration-sys" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" dependencies = [ - "adler", + "core-foundation-sys", + "libc", ] [[package]] -name = "mio" -version = "0.8.11" +name = "tabwriter" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +checksum = "fce91f2f0ec87dff7e6bcbbeb267439aa1188703003c6055193c821487400432" dependencies = [ - "libc", - "wasi", - "windows-sys", + "unicode-width 0.2.0", ] [[package]] -name = "native-tls" -version = "0.2.11" +name = "tempfile" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ - "lazy_static", - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.3", + "windows-sys 0.61.2", ] [[package]] -name = "nibble_vec" -version = "0.1.0" +name = "terminal-colorsaurus" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +checksum = "7a46bb5364467da040298c573c8a95dbf9a512efc039630409a03126e3703e90" dependencies = [ - "smallvec", + "cfg-if", + "libc", + "memchr", + "mio", + "terminal-trx", + "windows-sys 0.61.2", + "xterm-color", ] [[package]] -name = "nix" -version = "0.26.4" +name = "terminal-trx" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +checksum = "3b3f27d9a8a177e57545481faec87acb45c6e854ed1e5a3658ad186c106f38ed" dependencies = [ - "bitflags 1.3.2", "cfg-if", "libc", + "windows-sys 0.61.2", ] [[package]] -name = "num_cpus" -version = "1.16.0" +name = "terminal_size" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7" dependencies = [ - "hermit-abi", - "libc", + "rustix 0.38.44", + "windows-sys 0.48.0", ] [[package]] -name = "object" -version = "0.32.1" +name = "terminal_size" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ - "memchr", + "rustix 1.1.3", + "windows-sys 0.60.2", ] [[package]] -name = "once_cell" -version = "1.18.0" +name = "thiserror" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] [[package]] -name = "openssl" -version = "0.10.72" +name = "thiserror" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "bitflags 2.4.1", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", + "thiserror-impl 2.0.18", ] [[package]] -name = "openssl-macros" -version = "0.1.1" +name = "thiserror-impl" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] -name = "openssl-probe" -version = "0.1.5" +name = "thiserror-impl" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] [[package]] -name = "openssl-src" -version = "300.2.3+3.2.1" +name = "tiny-keccak" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cff92b6f71555b61bb9315f7c64da3ca43d87531622120fea0195fc761b4843" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" dependencies = [ - "cc", + "crunchy", ] [[package]] -name = "openssl-sys" -version = "0.9.109" +name = "tinystr" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ - "cc", - "libc", - "openssl-src", - "pkg-config", - "vcpkg", + "displaydoc", + "zerovec", ] [[package]] -name = "option-ext" -version = "0.2.0" +name = "tokio" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] [[package]] -name = "parking_lot" -version = "0.12.1" +name = "tokio-macros" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ - "lock_api", - "parking_lot_core", + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] -name = "parking_lot_core" -version = "0.9.9" +name = "tokio-native-tls" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.4.1", - "smallvec", - "windows-targets", + "native-tls", + "tokio", ] [[package]] -name = "percent-encoding" -version = "2.3.0" +name = "tokio-rustls" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] [[package]] -name = "pest" -version = "2.8.0" +name = "tokio-util" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ - "memchr", - "thiserror 2.0.12", - "ucd-trie", + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", ] [[package]] -name = "pest_derive" -version = "2.8.0" +name = "toml" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d725d9cfd79e87dccc9341a2ef39d1b6f6353d68c4b33c177febbe1a402c97c5" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ - "pest", - "pest_generator", + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", ] [[package]] -name = "pest_generator" -version = "2.8.0" +name = "toml_datetime" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db7d01726be8ab66ab32f9df467ae8b1148906685bbe75c82d1e65d7f5b3f841" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.100", + "serde", ] [[package]] -name = "pest_meta" -version = "2.8.0" +name = "toml_edit" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f9f832470494906d1fca5329f8ab5791cc60beb230c74815dff541cbd2b5ca0" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "once_cell", - "pest", - "sha2", + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", ] [[package]] -name = "pin-project" -version = "1.1.5" +name = "toml_write" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ - "pin-project-internal", + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", ] [[package]] -name = "pin-project-internal" -version = "1.1.5" +name = "tower-http" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", ] [[package]] -name = "pin-project-lite" -version = "0.2.13" +name = "tower-layer" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] -name = "pin-utils" -version = "0.1.0" +name = "tower-service" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] -name = "pkg-config" -version = "0.3.27" +name = "tracing" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] [[package]] -name = "proc-macro2" -version = "1.0.94" +name = "tracing-core" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ - "unicode-ident", + "once_cell", ] [[package]] -name = "quote" -version = "1.0.40" +name = "tree_magic_mini" +version = "3.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" dependencies = [ - "proc-macro2", + "memchr", + "nom 8.0.0", + "petgraph", ] [[package]] -name = "radix_trie" -version = "0.2.1" +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tui-input" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +checksum = "74f679521b7fd35e17fbca58ec5aac64c5d331e54a9034510ec26b193ffd7597" dependencies = [ - "endian-type", - "nibble_vec", + "crossterm", + "ratatui", + "unicode-width 0.2.0", ] [[package]] -name = "redox_syscall" -version = "0.2.16" +name = "tui-textarea" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" dependencies = [ - "bitflags 1.3.2", + "crossterm", + "ratatui", + "unicode-width 0.2.0", ] [[package]] -name = "redox_syscall" -version = "0.4.1" +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" -dependencies = [ - "bitflags 1.3.2", -] +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] -name = "redox_users" -version = "0.4.3" +name = "unicode-truncate" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ - "getrandom", - "redox_syscall 0.2.16", - "thiserror 1.0.50", + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", ] [[package]] -name = "regex" -version = "1.10.2" +name = "unicode-width" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] -name = "regex-automata" -version = "0.4.3" +name = "unicode-width" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] -name = "regex-syntax" -version = "0.8.2" +name = "unicode_categories" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" [[package]] -name = "reqwest" -version = "0.12.0" +name = "unsafe-libyaml" +version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58b48d98d932f4ee75e541614d32a7f44c889b72bd9c2e04d95edd135989df88" -dependencies = [ - "base64", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-tls", - "hyper-util", - "ipnet", - "js-sys", - "log", - "mime", - "native-tls", - "once_cell", - "percent-encoding", - "pin-project-lite", - "rustls-pemfile", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "system-configuration", - "tokio", - "tokio-native-tls", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "winreg", -] +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" [[package]] -name = "rustc-demangle" -version = "0.1.23" +name = "untrusted" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] -name = "rustix" -version = "0.38.21" +name = "url" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b426b0506e5d50a7d8dafcf2e81471400deb602392c7dd110815afb4eaf02a3" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ - "bitflags 2.4.1", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", + "form_urlencoded", + "idna", + "percent-encoding", + "serde", ] [[package]] -name = "rustls-pemfile" -version = "1.0.4" +name = "urlencoding" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64", -] +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" [[package]] -name = "rustyline" -version = "12.0.0" +name = "utf8_iter" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "994eca4bca05c87e86e15d90fc7a91d1be64b4482b38cb2d27474568fe7c9db9" -dependencies = [ - "bitflags 2.4.1", - "cfg-if", - "clipboard-win", - "fd-lock", - "home", - "libc", - "log", - "memchr", - "nix", - "radix_trie", - "regex", - "scopeguard", - "unicode-segmentation", - "unicode-width", - "utf8parse", - "winapi", -] +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] -name = "ryu" -version = "1.0.15" +name = "utf8parse" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] -name = "schannel" -version = "0.1.22" +name = "vcpkg" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88" -dependencies = [ - "windows-sys", -] +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] -name = "scopeguard" -version = "1.2.0" +name = "version_check" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] -name = "security-framework" -version = "2.9.2" +name = "want" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", + "try-lock", ] [[package]] -name = "security-framework-sys" -version = "2.9.1" +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" -dependencies = [ - "core-foundation-sys", - "libc", -] +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "serde" -version = "1.0.190" +name = "wasip2" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91d3c334ca1ee894a2c6f6ad698fe8c435b76d504b13d436f0685d648d6d96f7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ - "serde_derive", + "wit-bindgen", ] [[package]] -name = "serde_derive" -version = "1.0.190" +name = "wasm-bindgen" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c5609f394e5c2bd7fc51efda478004ea80ef42fee983d5c67a65e34f32c0e3" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] -name = "serde_json" -version = "1.0.108" +name = "wasm-bindgen-futures" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ - "itoa", - "ryu", - "serde", + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", ] [[package]] -name = "serde_spanned" -version = "0.6.4" +name = "wasm-bindgen-macro" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12022b835073e5b11e90a14f86838ceb1c8fb0325b72416845c487ac0fa95e80" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ - "serde", + "quote", + "wasm-bindgen-macro-support", ] [[package]] -name = "serde_urlencoded" -version = "0.7.1" +name = "wasm-bindgen-macro-support" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", ] [[package]] -name = "serde_yaml" -version = "0.9.27" +name = "wasm-bindgen-shared" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cc7a1570e38322cfe4154732e5110f887ea57e22b76f4bfd32b5bdd3368666c" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", + "unicode-ident", ] [[package]] -name = "sha2" -version = "0.10.8" +name = "wayland-backend" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" dependencies = [ - "cfg-if", - "cpufeatures", - "digest", + "cc", + "downcast-rs", + "rustix 1.1.3", + "smallvec", + "wayland-sys", ] [[package]] -name = "signal-hook-registry" -version = "1.4.1" +name = "wayland-client" +version = "0.31.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" dependencies = [ - "libc", + "bitflags", + "rustix 1.1.3", + "wayland-backend", + "wayland-scanner", ] [[package]] -name = "slab" -version = "0.4.9" +name = "wayland-protocols" +version = "0.32.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" dependencies = [ - "autocfg", + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-scanner", ] [[package]] -name = "smallvec" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" - -[[package]] -name = "socket2" -version = "0.5.5" +name = "wayland-protocols-wlr" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" +checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" dependencies = [ - "libc", - "windows-sys", + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", ] [[package]] -name = "str-buf" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e08d8363704e6c71fc928674353e6b7c23dcea9d82d7012c8faf2a3a025f8d0" - -[[package]] -name = "syn" -version = "1.0.109" +name = "wayland-scanner" +version = "0.31.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" dependencies = [ "proc-macro2", + "quick-xml", "quote", - "unicode-ident", ] [[package]] -name = "syn" -version = "2.0.100" +name = "wayland-sys" +version = "0.31.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "pkg-config", ] [[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - -[[package]] -name = "system-configuration" -version = "0.5.1" +name = "web-sys" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "system-configuration-sys", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "system-configuration-sys" -version = "0.5.0" +name = "winapi" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" dependencies = [ - "core-foundation-sys", - "libc", + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", ] [[package]] -name = "tempfile" -version = "3.8.1" +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef1adac450ad7f4b3c28589471ade84f25f731a7a0fe30d71dfa9f60fd808e5" -dependencies = [ - "cfg-if", - "fastrand", - "redox_syscall 0.4.1", - "rustix", - "windows-sys", -] +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] -name = "thiserror" -version = "1.0.50" +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9a7210f5c9a7156bb50aa36aed4c95afb51df0df00713949448cf9e97d382d2" -dependencies = [ - "thiserror-impl 1.0.50", -] +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "thiserror" -version = "2.0.12" +name = "windows-core" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "thiserror-impl 2.0.12", + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] -name = "thiserror-impl" -version = "1.0.50" +name = "windows-implement" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] -name = "thiserror-impl" -version = "2.0.12" +name = "windows-interface" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.114", ] [[package]] -name = "tinyvec" -version = "1.6.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" -dependencies = [ - "tinyvec_macros", -] +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "tinyvec_macros" -version = "0.1.1" +name = "windows-registry" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] [[package]] -name = "tokio" -version = "1.38.2" +name = "windows-result" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68722da18b0fc4a05fdc1120b302b82051265792a1e1b399086e9b204b10ad3d" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "backtrace", - "bytes", - "libc", - "mio", - "num_cpus", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys", + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", ] [[package]] -name = "tokio-macros" -version = "2.3.0" +name = "windows-sys" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", + "windows-targets 0.48.5", ] [[package]] -name = "tokio-native-tls" -version = "0.3.1" +name = "windows-sys" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "native-tls", - "tokio", + "windows-targets 0.52.6", ] [[package]] -name = "tokio-util" -version = "0.7.10" +name = "windows-sys" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", - "tracing", + "windows-targets 0.52.6", ] [[package]] -name = "toml" -version = "0.8.8" +name = "windows-sys" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1a195ec8c9da26928f773888e0742ca3ca1040c6cd859c919c9f59c1954ab35" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", + "windows-targets 0.53.5", ] [[package]] -name = "toml_datetime" -version = "0.6.5" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "serde", + "windows-link", ] [[package]] -name = "toml_edit" -version = "0.21.0" +name = "windows-targets" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d34d383cd00a163b4a5b85053df514d45bc330f6de7737edfe0a93311d1eaa03" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "winnow", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", ] [[package]] -name = "tower" -version = "0.4.13" +name = "windows-targets" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "futures-core", - "futures-util", - "pin-project", - "pin-project-lite", - "tokio", - "tower-layer", - "tower-service", - "tracing", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] -name = "tower-layer" -version = "0.3.2" +name = "windows-targets" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] [[package]] -name = "tower-service" -version = "0.3.2" +name = "windows_aarch64_gnullvm" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] -name = "tracing" -version = "0.1.40" +name = "windows_aarch64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" -dependencies = [ - "log", - "pin-project-lite", - "tracing-core", -] +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] -name = "tracing-core" -version = "0.1.32" +name = "windows_aarch64_gnullvm" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" -dependencies = [ - "once_cell", -] +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] -name = "try-lock" -version = "0.2.4" +name = "windows_aarch64_msvc" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] -name = "typenum" -version = "1.18.0" +name = "windows_aarch64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] -name = "ucd-trie" -version = "0.1.7" +name = "windows_aarch64_msvc" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] -name = "unicode-bidi" -version = "0.3.13" +name = "windows_i686_gnu" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] -name = "unicode-ident" -version = "1.0.12" +name = "windows_i686_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] -name = "unicode-normalization" -version = "0.1.22" +name = "windows_i686_gnu" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" -dependencies = [ - "tinyvec", -] +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] -name = "unicode-segmentation" -version = "1.10.1" +name = "windows_i686_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] -name = "unicode-width" -version = "0.1.11" +name = "windows_i686_gnullvm" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] -name = "unsafe-libyaml" -version = "0.2.11" +name = "windows_i686_msvc" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] -name = "url" -version = "2.4.1" +name = "windows_i686_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", -] +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] -name = "urlencoding" -version = "2.1.3" +name = "windows_i686_msvc" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] -name = "utf8parse" -version = "0.2.1" +name = "windows_x86_64_gnu" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] -name = "vcpkg" -version = "0.2.15" +name = "windows_x86_64_gnu" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] -name = "version_check" -version = "0.9.5" +name = "windows_x86_64_gnu" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] -name = "want" -version = "0.3.1" +name = "windows_x86_64_gnullvm" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +name = "windows_x86_64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] -name = "wasm-bindgen" -version = "0.2.88" +name = "windows_x86_64_gnullvm" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7daec296f25a1bae309c0cd5c29c4b260e510e6d813c286b19eaadf409d40fce" -dependencies = [ - "cfg-if", - "wasm-bindgen-macro", -] +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] -name = "wasm-bindgen-backend" -version = "0.2.88" +name = "windows_x86_64_msvc" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e397f4664c0e4e428e8313a469aaa58310d302159845980fd23b0f22a847f217" -dependencies = [ - "bumpalo", - "log", - "once_cell", - "proc-macro2", - "quote", - "syn 2.0.100", - "wasm-bindgen-shared", -] +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] -name = "wasm-bindgen-futures" -version = "0.4.38" +name = "windows_x86_64_msvc" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afec9963e3d0994cac82455b2b3502b81a7f40f9a0d32181f7528d9f4b43e02" -dependencies = [ - "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", -] +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "wasm-bindgen-macro" -version = "0.2.88" +name = "windows_x86_64_msvc" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5961017b3b08ad5f3fe39f1e79877f8ee7c23c5e5fd5eb80de95abc41f1f16b2" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.88" +name = "winnow" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5353b8dab669f5e10f5bd76df26a9360c748f054f862ff5f3f8aae0c7fb3907" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", - "wasm-bindgen-backend", - "wasm-bindgen-shared", + "memchr", ] [[package]] -name = "wasm-bindgen-shared" -version = "0.2.88" +name = "wit-bindgen" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d046c5d029ba91a1ed14da14dca44b68bf2f124cfbaf741c54151fdb3e0750b" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] -name = "web-sys" -version = "0.3.65" +name = "wl-clipboard-rs" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5db499c5f66323272151db0e666cd34f78617522fb0c1604d31a27c50c206a85" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" dependencies = [ - "js-sys", - "wasm-bindgen", + "libc", + "log", + "os_pipe", + "rustix 1.1.3", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", ] [[package]] -name = "winapi" -version = "0.3.9" +name = "writeable" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "gethostname", + "rustix 1.1.3", + "x11rb-protocol", ] [[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" +name = "x11rb-protocol" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" +name = "xterm-color" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +checksum = "7008a9d8ba97a7e47d9b2df63fcdb8dade303010c5a7cd5bf2469d4da6eba673" [[package]] -name = "windows-sys" -version = "0.48.0" +name = "yoke" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ - "windows-targets", + "stable_deref_trait", + "yoke-derive", + "zerofrom", ] [[package]] -name = "windows-targets" -version = "0.48.5" +name = "yoke-derive" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" +name = "zerocopy" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] [[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" +name = "zerocopy-derive" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] [[package]] -name = "windows_i686_gnu" -version = "0.48.5" +name = "zerofrom" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] [[package]] -name = "windows_i686_msvc" -version = "0.48.5" +name = "zerofrom-derive" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] [[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" +name = "zeroize" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" +name = "zerotrie" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] [[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" +name = "zerovec" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] [[package]] -name = "winnow" -version = "0.5.19" +name = "zerovec-derive" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829846f3e3db426d4cee4510841b71a8e58aa2a76b1132579487ae430ccd9c7b" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ - "memchr", + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] -name = "winreg" -version = "0.50.0" +name = "zmij" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if", - "windows-sys", -] +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" diff --git a/Cargo.toml b/Cargo.toml index ac2a217..799fabc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,11 +6,21 @@ license = "Apache-2.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +name = "fb" +crate-type = ["staticlib", "rlib"] + [dependencies] -rustyline = { version = "12.0.0", features = ["case_insensitive_history_search"] } +ratatui = "0.29" +tui-textarea = "0.7" +crossterm = "0.28" gumdrop = { version = "0.8.1", features = ["default_expr"] } reqwest = { version = "0.12", features = ["json", "http2"] } -openssl = { version = "*", features = ["vendored"] } +hyper = { version = "1", features = ["client", "http1"] } +hyper-util = { version = "0.1", features = ["tokio"] } +http-body-util = "0.1" +bytes = "1" +openssl = { version = "*", features = ["vendored"] } tokio = { version = "1", features = ["full"] } tokio-util = "0.7.10" dirs = "5.0" @@ -23,3 +33,7 @@ toml = "0.8" urlencoding = "2.1" pest = "2.7" pest_derive = "2.7" +terminal_size = "0.3" +csvlens = "0.14" +nucleo-matcher = "0.3.1" +sqlformat = "0.5.0" diff --git a/README.md b/README.md index 6c40fe5..cb0736d 100644 --- a/README.md +++ b/README.md @@ -1,249 +1,465 @@ # fb-cli -Firebolt CLI; work with [Firebolt](https://www.firebolt.io/) and [Firebolt Core](https://github.com/firebolt-db/firebolt-core). +Command-line client for [Firebolt](https://www.firebolt.io/) and [Firebolt Core](https://github.com/firebolt-db/firebolt-core). -## Examples +## Quick Start ``` -➤ fb select 42 - ?column? ---------- - 42 +fb --core "SELECT 42" +``` -Time: 41.051ms ``` +fb --host api.us-east-1.app.firebolt.io \ + --extra account_id= \ + --jwt '' \ + -d +``` + +## Install -### REPL +1. Install `cargo`: https://doc.rust-lang.org/cargo/getting-started/installation.html +2. Install system dependencies (Ubuntu): `sudo apt install pkg-config libssl-dev` +3. Clone, build, and install: ``` -➤ fb -=> select 42 - ?column? ---------- - 42 +git clone git@github.com:firebolt-db/fb-cli.git +cd fb-cli +cargo install --path . --locked +``` -Time: 40.117ms +Run `fb` (or `~/.cargo/bin/fb` if cargo's bin directory is not on `$PATH`). -=> create table t (a int); -Table 't' already exists. +## Usage -Time: 87.747ms +**Single query (non-interactive):** +``` +fb "SELECT 42" +fb -c "SELECT 42" +fb --core "SELECT * FROM information_schema.tables LIMIT 5" +``` -=> insert into t select * from generate_series(1, 100000000); -/ +**Interactive REPL:** +``` +fb +fb --core +``` + +**Pipe mode:** +``` +echo "SELECT 1; SELECT 2;" | fb --core +cat queries.sql | fb --core ``` -Also support history + search in it (`CTRL+R`). +## Interactive REPL -## Help +The REPL uses a full-terminal layout: ``` -➤ fb --help -Usage: fb [OPTIONS] +┌───────────────────────────────────────────────┐ +│ OUTPUT (scrollable) │ +│ previous queries, results, timing, errors │ +│ │ +├───────────────────────────────────────────────┤ +│ INPUT (multi-line editor) │ +│ SELECT * │ +│ FROM orders │ +│ │ +├───────────────────────────────────────────────┤ +│ localhost:3473 | firebolt Tab complete … │ +└───────────────────────────────────────────────┘ +``` -Positional arguments: - query Query command(s) to execute. If not specified, starts the REPL +Enter submits the query when SQL is complete (ends with `;`). An incomplete statement gets a newline instead, allowing natural multi-line editing. + +### Key Bindings + +| Key | Action | +|-----|--------| +| `Enter` | Submit query (if SQL complete) or insert newline | +| `Shift+Enter` / `Alt+Enter` | Always insert newline (even after `;`) | +| `Ctrl+C` | Cancel current input, or cancel an in-flight query | +| `Ctrl+D` | Exit | +| `Ctrl+H` | Show / hide help popup | +| `Ctrl+Up` / `Ctrl+Down` | Navigate history (older / newer) | +| `Ctrl+R` | Reverse history search | +| `Tab` | Open completion popup (or advance to next item) | +| `Shift+Tab` | Navigate completion popup backward | +| `Ctrl+Space` | Fuzzy schema search (tables, columns, functions) | +| `Ctrl+V` | Open last result in interactive viewer (csvlens) | +| `Ctrl+E` | Open current query in `$EDITOR` (falls back to `vi`) | +| `Ctrl+Z` | Undo last edit | +| `Ctrl+Y` | Redo | +| `Alt+F` | Format SQL in the editor (uppercase keywords, 2-space indent) | +| `Page Up` / `Page Down` | Scroll output pane | +| `Escape` | Close any open popup | + +### Tab Completion + +Tab completion suggests table names, column names, and functions based on the current cursor context (FROM clause, SELECT list, WHERE clause, etc.). The schema is fetched from the server and cached for `--completion-cache-ttl` seconds (default 300). + +When all suggestions share a common prefix, that prefix is completed immediately (bash-style). A second Tab opens the popup to choose among remaining candidates. + +`Ctrl+Space` opens a fuzzy search overlay that searches the full schema regardless of cursor context. + +### Ctrl+R History Search + +Incremental reverse search over the session history. Type to filter; `Ctrl+R` again for the next older match; `Up`/`Down` to navigate matches; `Left`/`Right` to move within the search query; `Ctrl+A` to jump to the start; `Enter` to accept; `Esc` to cancel. + +### Ctrl+V Viewer + +Opens the last query result in [csvlens](https://github.com/YS-L/csvlens) — a full-screen interactive table viewer with sorting, filtering, and fuzzy search. Requires a client-side output format (`client:*`). + +## Slash Commands + +Type these directly in the REPL (or pass with `-c`): + +| Command | Description | +|---------|-------------| +| `/exit` | Exit the REPL (also: `/quit`, `exit`, `quit`) | +| `/refresh` | Refresh the schema completion cache | +| `/view` | Open last result in csvlens viewer (same as `Ctrl+V`) | +| `/qh [limit] [minutes]` | Show recent query history. Default: 100 rows, last 60 minutes | +| `/run @` | Execute all SQL queries from a file (Tab completes path) | +| `/run ` | Execute an inline SQL query | +| `/benchmark [N] @\|` | Benchmark a query: 1 warmup + N timed runs (default N=3) | +| `/watch [N] @\|` | Re-run query every N seconds (default 5); `Ctrl+C` stops | +| `set key=value;` | Set a server-side query parameter | +| `unset key;` | Remove a query parameter | +| `.format = value` | Set client output format (e.g. `client:auto`, `client:vertical`) | +| `.completion = on\|off` | Enable or disable tab completion | + +### `@` syntax + +`/run`, `/benchmark`, and `/watch` all accept either an inline SQL query or a reference to a file using the `@` prefix: -Optional arguments: - -c, --command COMMAND Run a single command and exit - -C, --core Preset of settings to connect to Firebolt Core - -h, --host HOSTNAME Hostname (and port) to connect to - -d, --database DATABASE Database name to use - -f, --format FORMAT Output format (e.g., TabSeparatedWithNames, PSQL, JSONLines_Compact, Vertical, ...) - -e, --extra EXTRA Extra settings in the form --extra = - -l, --label LABEL Query label for tracking or identification - -j, --jwt JWT JWT for authentication - --sa-id SA-ID Service Account ID for OAuth authentication - --sa-secret SA-SECRET Service Account Secret for OAuth authentication - --jwt-from-file Load JWT from file (~/.firebolt/jwt) - --oauth-env OAUTH-ENV OAuth environment to use (e.g., 'app' or 'staging'). Used for Service Account authentication (default: staging) - -v, --verbose Enable extra verbose output - --concise Suppress time statistics in output - --hide-pii Hide URLs that may contain PII in query parameters - --no-spinner Disable the spinner in CLI output - --update-defaults Update default configuration values - -V, --version Print version - --help Show help message and exit ``` +/run @~/queries/report.sql +/run SELECT 42; -## Install +/benchmark 10 @query.sql +/benchmark SELECT count(*) FROM large_table; -1) Install `cargo`: https://doc.rust-lang.org/cargo/getting-started/installation.html - 1) Add `source "$HOME/.cargo/env"` to your `~/.bashrc` (or `~/.zshrc`). -2) Install `pkg-config`: `sudo apt install pkg-config` (a default dependency for Ubuntu) -3) Install `openssl`: `sudo apt install libssl-dev` (a default dependency for Ubuntu) -4) Clone & Build & Install: +/watch 5 @monitor.sql +/watch 2 SELECT now(); ``` -git clone git@github.com:firebolt-db/fb-cli.git -cd fb-cli -cargo install --path . --locked + +Tab after `/run ` suggests `@`. Tab after `/run @` completes file names. +Tab at `/` shows all available commands. + +### `/qh` — Query History + +`/qh [limit] [minutes]` + +- `limit` — maximum number of rows to return (default: 100) +- `minutes` — look back window in minutes (default: 60) + +``` +/qh -- last 100 queries from the past hour +/qh 50 -- last 50 queries from the past hour +/qh 200 1440 -- last 200 queries from the past day ``` -4) That's it: you should be able to run `fb` // or at least `~/.cargo/bin/fb` if cargo env isn't caught up. -## Shortcuts +Shorthand for: -Most of them from https://github.com/kkawakam/rustyline: +```sql +SELECT * +FROM information_schema.engine_user_query_history +WHERE start_time > now() - interval ' minutes' +ORDER BY start_time DESC +LIMIT ; +``` -| Keystroke | Action | -| --------------------- | --------------------------------------------------------------------------- | -| Enter | Finish the line entry | -| Ctrl-R | Reverse Search history (Ctrl-S forward, Ctrl-G cancel) | -| Ctrl-U | Delete from start of line to cursor | -| Ctrl-W | Delete word leading up to cursor (using white space as a word boundary) | -| Ctrl-Y | Paste from Yank buffer | -| Ctrl-\_ | Undo | +### `/benchmark` — Benchmark Mode -Some of them specific to `fb`: -| Keystroke | Action | -| --------------------- | --------------------------------------------------------------------------- | -| Ctrl-C | Cancel current input. | -| Ctrl-O | Insert a newline | +Runs a query multiple times, discards the first result as warmup, and reports timing statistics. Result cache is automatically disabled for accurate measurements. +``` +/benchmark SELECT count(*) FROM large_table +/benchmark 10 SELECT count(*) FROM large_table +``` -## Defaults +Output example: +``` + warmup: 412.3ms + run 1/3: 398.1ms [result rows...] + run 2/3: 401.7ms + run 3/3: 395.4ms +Results: min=395.4ms avg=398.4ms p90=401.7ms max=401.7ms +``` + +`Ctrl+C` cancels a benchmark in progress. + +## Output Formats + +### Client-Side Rendering (default) + +The default format is `client:auto`, which fetches results as JSON and renders them in the terminal. The layout adapts to the number of columns: + +- **Horizontal** (few columns): standard bordered table +- **Vertical** (many columns or `client:vertical`): one column per row +- **Auto** (`client:auto`): automatically picks horizontal or vertical based on column count and terminal width + +``` +=> SELECT query_id, status, duration_usec FROM information_schema.engine_query_history LIMIT 3; ++------------------+---------+--------------+ +| query_id | status | duration_usec | ++==================+=========+==============+ +| abc123... | ENDED | 41051 | +| def456... | ENDED | 40117 | +| ghi789... | ENDED | 87747 | ++------------------+---------+--------------+ +Time: 15.2ms +``` + +Override with `--format client:vertical` or set at runtime: `.format = client:vertical` + +The default format is `client:auto` in all modes (interactive REPL, single-query, and pipe mode). -Can update defaults one and for all by specifying `--update-defaults`: during this application old defaults are **not** applied. +### Server-Side Rendering + +Pass any Firebolt output format name (without a `client:` prefix) to receive raw server-rendered output: + +``` +fb --format PSQL "SELECT 42" +fb --format JSON_Compact "SELECT 42" +fb --format TabSeparatedWithNamesAndTypes "SELECT 42" +``` -New defaults are going to be stored at `~/.firebolt/fb_config`. +Supported formats: `PSQL`, `JSON_Compact`, `JSON_CompactLimited`, `JSONLines_Compact`, `TabSeparatedWithNamesAndTypes`. +### Changing Format at Runtime ``` -~ ➤ fb select 42 - ?column? ---------- - 42 +.format = client:vertical -- client-side vertical +.format = client:horizontal -- client-side horizontal +.format = -- reset to default (client:auto) +``` -Time: 40.342ms +For server-side formats use `--format` on the command line or `set output_format=PSQL;` at runtime. -~ ➤ fb select 42 --format CSVWithNames --concise --update-defaults -"?column?" -42 +## Client Settings -~ ➤ fb select 42 -"?column?" -42 +Settings that only affect the CLI use the `.setting = value` syntax: -~ ➤ fb select 42 --verbose # defauls are merged with new args -URL: http://localhost:8123/?database=local_dev_db&mask_internal_errors=1 -QUERY: select 42 -"?column?" -42 +``` +.format = client:auto -- client-side rendering (default) +.format = client:vertical -- always vertical layout +.format = client:horizontal -- always horizontal layout +.completion = off -- disable tab completion +.completion = on -- re-enable tab completion +.format -- show current format +.completion -- show current completion state ``` -## Queries against FB 2.0 using Service Account +## Set and Unset -Specify: -- Service Account ID; -- Service Account Secret; -- Environment +Change server-side query parameters at runtime without restarting: -Note: The token is saved in `~/.firebolt/fb_sa_token/` and will be reused if the account ID and secret match and the token is less than half an hour old. +```sql +set database = my_db; +set engine = my_engine; +set enable_result_cache = false; +unset enable_result_cache; +``` + +Active non-default settings are shown in grey between the query echo and its result. +## Defaults + +Save your preferred flags so you don't have to repeat them: ``` -➤ fb --sa-id=${SA_ID} --sa-secret=${SA_SECRET} --oauth-env=app \ - -h ${ACCOUNT_ID}.api.us-east-1.app.firebolt.io -d ${DATABASE_NAME} +fb --host my-host.firebolt.io --update-defaults ``` -Read more about getting service accounts [here](https://docs.firebolt.io/guides/managing-your-organization/service-accounts). +Saved defaults are stored in `~/.firebolt/fb_config` and merged with any flags you provide at run time. The `--format` flag is intentionally not saved to defaults — always specify it explicitly if you need a non-default format. + +## Exit Codes -## Queries against FB 2.0 +| Code | Meaning | +|------|---------| +| `0` | All queries succeeded | +| `1` | One or more queries failed (bad SQL, permission denied, HTTP 400) | +| `2` | System/infrastructure error (connection refused, auth failure, HTTP 4xx/5xx) | -Specify: -- host; -- account_id; -- JWT token (can be obtained from browser or other authentication methods); +## Parameterized Queries +Use `-p` / `--param` to pass positional parameters in non-interactive mode. The first `-p` value becomes `$1`, the second `$2`, and so on. + +```bash +fb --core -p 42 -p 'Alice' \ + "SELECT * FROM users WHERE id = \$1 AND name = \$2" ``` -➤ fb --host api.us-east-1.app.firebolt.io --verbose --extra account_id=12312312312 --jwt 'eyJhbGciOiJSUzI1NiI...' -=> show engines -URL: https://api.us-east-1.app.firebolt.io/?database=db_1&account_id=12312312&output_format=JSON&advanced_mode=1 -QUERY: show engines -┌─engine_name─────────────┬─engine_owner────────────────┬─type─┬─nodes─┬─clusters─┬─status───────────────┬─auto_start─┬─auto_stop─┬─initially_stopped─┬─url────────────────────────────────────────────────────────────────────────────────────────────────────┬─default_database───────────────────┬─version─┬─last_started──────────────────┬─last_stopped──────────────────┬─description─┐ -│ pre_demo_engine1 │ user@firebolt.io │ S │ 2 │ 1 │ ENGINE_STATE_STOPPED │ t │ 20 │ f │ api.us-east-1.app.firebolt.io?account_id=1321231&engine=pre_demo_engine1 │ │ latest │ 2024-02-07 01:19:19.81689+00 │ 2024-02-07 01:44:15.930845+00 │ │ -│ pre_demo_engine2 │ user@firebolt.io │ S │ 2 │ 1 │ ENGINE_STATE_STOPPED │ t │ 20 │ f │ api.us-east-1.app.firebolt.io?account_id=1321231&engine=pre_demo_engine2 │ pre_demo_validation_testdb1 │ latest │ 2024-02-07 01:21:36.274962+00 │ 2024-02-07 02:32:05.403539+00 │ │ -└─────────────────────────┴─────────────────────────────┴──────┴───────┴──────────┴──────────────────────┴────────────┴───────────┴───────────────────┴────────────────────────────────────────────────────────────────────────────────────────────────────────┴────────────────────────────────────┴─────────┴───────────────────────────────┴───────────────────────────────┴─────────────┘ +Parameters are automatically typed from the value: + +| Value passed | JSON sent | Example | +|---|---|---| +| Integer | number | `-p 42` → `42` | +| Float | number | `-p 3.14` → `3.14` | +| `true` / `false` | boolean | `-p true` → `true` | +| `NULL` | null | `-p NULL` → `null` | +| Anything else | string | `-p hello` → `"hello"` | -=> set engine=user5_engine_1 -URL: https://api.us-east-1.app.firebolt.io/?database=db_1&engine=user5_engine_1&account_id=1321231&output_format=JSON&advanced_mode=1 +Works in all non-interactive modes: -=> select 42 -URL: https://api.us-east-1.app.firebolt.io/?database=db_1&engine=user5_engine_1&account_id=1321231&output_format=JSON&advanced_mode=1 -QUERY: select 42 - ?column? ---------- - 42 +```bash +# Single-query +fb --core -p 10 -p 20 "SELECT \$1 + \$2" -Time: 275.639ms +# Pipe mode +echo "SELECT \$1, \$2;" | fb --core -p hello -p world + +# With a file +/run @query.sql # in REPL (use set/unset for runtime params instead) ``` -## Set and Unset +Firebolt validates and escapes parameter values server-side, so they are safe against SQL injection. + +## Scripting + +### stdout vs stderr + +Query results are always written to **stdout**. Error messages are written to **stderr**. Timing statistics follow the table on **stdout** for client-side formats; server-side formats produce no timing output. You can redirect them independently: + +```bash +# Save server-rendered output (no stats) +fb --core --format TabSeparatedWithNamesAndTypes "SELECT * FROM my_table" > results.tsv + +# Save client-side table + stats together +fb --core "SELECT * FROM my_table" > results.txt + +# Save only results, discard stats (stderr) +fb --core "SELECT * FROM my_table" > results.txt 2>/dev/null +``` -In interactive mode one can dynamically update extra arguments: -- `set key=value;` to set the argument; -- `unset key;` to unset it. +### JSON output +Use `JSON_Compact` for structured output that is easy to process with tools like `jq`: + +```bash +fb --core --format JSON_Compact "SELECT 1 AS n, 'hello' AS msg" +# {"meta":[{"name":"n","type":"int"},{"name":"msg","type":"text"}],"data":[[1,"hello"]],...} + +# Extract with jq +fb --core --format JSON_Compact "SELECT count(*) AS n FROM my_table" \ + | jq '.data[0][0]' +``` + +Use `JSONLines_Compact` for streaming-friendly line-delimited JSON (one message per line): + +```bash +fb --core --format JSONLines_Compact "SELECT 42 AS value" \ + | grep '^{"message_type":"data"' \ + | jq '.data[0][0]' +``` + +### Exit codes in scripts + +```bash +fb --core "SELECT ..." +case $? in + 0) echo "ok" ;; + 1) echo "query error — check your SQL" ;; + 2) echo "system error — check connection or credentials" ;; +esac ``` -=> select E'qqq'; -URL: http://localhost:8123/?database=local_dev_db -QUERY: select E'qqq'; - ?column? ---------- - qqq -Time: 40.745ms +```bash +# Fail fast on any error +set -e +fb --core "INSERT INTO log SELECT now(), 'start';" +fb --core "SELECT count(*) FROM my_table;" +fb --core "INSERT INTO log SELECT now(), 'done';" +``` -=> set format = Vertical; -=> select E'qqq'; -URL: http://localhost:8123/?database=local_dev_db -QUERY: select E'qqq'; -Row 1: -────── -?column?: qqq +### Pipe mode -Time: 38.888ms +When stdin is not a terminal, fb reads queries line-by-line. All queries are executed even if one fails; the exit code reflects the worst failure: -=> set cool_mode=disabled; -=> select E'qqq'; -URL: http://localhost:8123/?database=local_dev_db&cool_mode=disabled -QUERY: select E'qqq'; -Unknown setting cool_mode +```bash +{ + echo "SELECT 1;" + echo "SELECT 2;" + echo "SELECT 3;" +} | fb --core --format TabSeparatedWithNamesAndTypes +``` -Time: 36.802ms +## Firebolt Core -=> unset cool_mode -=> select E'qqq'; -URL: http://localhost:8123/?database=local_dev_db -QUERY: select E'qqq'; -Row 1: -────── -?column?: qqq +`--core` is a shortcut for `--host localhost:3473 --database firebolt` with no authentication required: -Time: 39.395ms +``` +fb --core +fb --core "SELECT 42" +``` -=> set enable_result_cache=disabled; -=> select E'qqq'; -URL: http://localhost:8123/?database=local_dev_db&enable_result_cache=disabled -QUERY: select E'qqq'; -Row 1: -────── -qqq: qqq +## Authentication -Time: 41.671ms +### JWT -=> unset enable_result_cache; -=> select E'qqq'; -URL: http://localhost:8123/?database=local_dev_db -QUERY: select E'qqq'; -Row 1: -────── -?column?: qqq +``` +fb --jwt 'eyJhbGci...' +fb --jwt-from-file # reads ~/.firebolt/jwt +``` -Time: 39.453ms +### Service Account -=> ``` +fb --sa-id --sa-secret --oauth-env app \ + --host .api.us-east-1.app.firebolt.io \ + -d +``` + +The token is cached in `~/.firebolt/fb_sa_token` and reused for up to 30 minutes. + +Read more: [Firebolt Service Accounts](https://docs.firebolt.io/guides/managing-your-organization/service-accounts) + +## All Flags + +``` +Usage: fb [OPTIONS] [query...] + +Positional arguments: + query Query to execute (starts REPL if omitted) + +Optional arguments: + -c, --command COMMAND Run a single command and exit + -C, --core Connect to Firebolt Core (localhost:3473) + -h, --host HOSTNAME Hostname and port + -d, --database DATABASE Database name + -f, --format FORMAT Output format (client:auto, client:vertical, + client:horizontal, PSQL, JSON_Compact, + JSONLines_Compact, TabSeparatedWithNamesAndTypes, ...) + -e, --extra NAME=VALUE Extra query parameters (repeatable) + -p, --params VALUE Query parameter: first -p is $1, second is $2, ... + Values auto-typed: int, float, bool, NULL, or string + -l, --label LABEL Query label for tracking + -j, --jwt JWT JWT token for authentication + --sa-id SA-ID Service Account ID + --sa-secret SA-SECRET Service Account Secret + --jwt-from-file Load JWT from ~/.firebolt/jwt + --oauth-env ENV OAuth environment: app or staging (default: staging) + -v, --verbose Verbose output (shows URL, query text) + --hide-pii Hide URLs containing query parameters + --no-color Disable syntax highlighting + --no-completion Disable tab completion + --completion-cache-ttl SECS Schema cache TTL in seconds (default: 300) + --min-col-width N Min column width before vertical mode (default: 15) + --max-cell-length N Max cell content length before truncation (default: 1000) + --update-defaults Save current flags as defaults + -V, --version Print version + --help Show help +``` + +## Files + +| Path | Purpose | +|------|---------| +| `~/.firebolt/fb_config` | Saved defaults (YAML) | +| `~/.firebolt/fb_history` | REPL history (up to 10,000 entries) | +| `~/.firebolt/fb_sa_token` | Cached service account token | +| `~/.firebolt/jwt` | JWT token file (used with `--jwt-from-file`) | ## License diff --git a/src/args.rs b/src/args.rs index 4e4d4c7..929ee2f 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1,4 +1,4 @@ -use gumdrop::Options; +use gumdrop::{Options, ParsingStyle}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fs; @@ -20,6 +20,20 @@ impl Or for String { } } +// Default value functions for serde +fn default_min_col_width() -> usize { + 15 +} + +fn default_max_cell_length() -> usize { + 1000 +} + + +fn default_cache_ttl() -> u64 { + 300 +} + #[derive(Clone, Debug, Options, Deserialize, Serialize)] pub struct Args { #[options(help = "Run a single command and exit")] @@ -38,8 +52,8 @@ pub struct Args { #[serde(skip_serializing, skip_deserializing)] pub database: String, - #[options(help = "Output format (e.g., TabSeparatedWithNames, PSQL, JSONLines_Compact, Vertical, ...)")] - #[serde(default)] + #[options(help = "Output format (client:auto, client:vertical, client:horizontal, PSQL, JSON_Compact, JSONLines_Compact, TabSeparatedWithNamesAndTypes, ...)")] + #[serde(skip_serializing, skip_deserializing)] pub format: String, #[options(help = "Extra settings in the form --extra =")] @@ -66,7 +80,6 @@ pub struct Args { #[serde(default)] pub jwt_from_file: bool, - #[options( no_short, help = "OAuth environment to use (e.g., 'app' or 'staging'). Used for Service Account authentication", @@ -79,35 +92,87 @@ pub struct Args { #[serde(default)] pub verbose: bool, - #[options(no_short, help = "Suppress time statistics in output")] - #[serde(default)] - pub concise: bool, - #[options(no_short, help = "Hide URLs that may contain PII in query parameters")] #[serde(default)] pub hide_pii: bool, - #[options(no_short, help = "Disable the spinner in CLI output")] - #[serde(default)] - pub no_spinner: bool, + #[options(no_short, help = "Minimum characters per column before switching to vertical mode", default = "15")] + #[serde(default = "default_min_col_width")] + pub min_col_width: usize, + + #[options(no_short, help = "Maximum cell content length before truncation", default = "1000")] + #[serde(default = "default_max_cell_length")] + pub max_cell_length: usize, + #[options(no_short, help = "Update default configuration values")] #[serde(skip_serializing, skip_deserializing)] pub update_defaults: bool, + #[options(no_short, help = "Disable syntax highlighting in REPL")] + #[serde(default)] + pub no_color: bool, + + #[options(no_short, help = "Disable auto-completion in REPL")] + #[serde(default)] + pub no_completion: bool, + + #[options(no_short, help = "Schema cache TTL in seconds (default: 300)")] + #[serde(default = "default_cache_ttl")] + pub completion_cache_ttl: u64, + #[options(help = "Print version")] #[serde(default)] pub version: bool, + #[options(no_short, help = "Connect to server via Unix domain socket at this path")] + #[serde(skip_serializing, skip_deserializing)] + pub unix_socket: String, + #[options(no_short, help = "Show help message and exit")] #[serde(skip_serializing, skip_deserializing)] pub help: bool, + #[options( + short = "p", + help = "Query parameter value; positional: first -p is $1, second is $2, etc. \ + Values are auto-typed: integers, floats, booleans, NULL, or strings." + )] + #[serde(skip_serializing, skip_deserializing)] + pub params: Vec, + #[options(free, help = "Query command(s) to execute. If not specified, starts the REPL")] #[serde(skip_serializing, skip_deserializing)] pub query: Vec, } +impl Args { + pub fn should_render_table(&self) -> bool { + // Client rendering when format starts with "client:" + self.format.starts_with("client:") + } + + /// Extract display mode from client: prefix + /// "client:auto" → "auto", "client:vertical" → "vertical", "PSQL" → "" + pub fn get_display_mode(&self) -> &str { + if self.format.starts_with("client:") { + &self.format[7..] // Skip "client:" prefix + } else { + "" + } + } + + pub fn is_vertical_display(&self) -> bool { + self.get_display_mode().eq_ignore_ascii_case("vertical") + } + + pub fn is_horizontal_display(&self) -> bool { + self.get_display_mode().eq_ignore_ascii_case("horizontal") + } + + +} + pub fn normalize_extras(extras: Vec, encode: bool) -> Result, Box> { let mut x: BTreeMap = BTreeMap::new(); @@ -139,8 +204,13 @@ pub fn normalize_extras(extras: Vec, encode: bool) -> Result } // Apply defaults and possibly update them. -#[allow(dead_code)] pub fn get_args() -> Result> { + let raw: Vec = std::env::args().collect(); + get_args_from(&raw) +} + +/// Like `get_args()` but parses from an explicit argv slice (raw[0] is the program name). +pub fn get_args_from(raw: &[String]) -> Result> { let config_path = config_path()?; let defaults: Args = if config_path.exists() { @@ -149,7 +219,14 @@ pub fn get_args() -> Result> { serde_yaml::from_str("")? }; - let mut args = Args::parse_args_default_or_exit(); + let skip = raw.len().min(1); + let mut args = match Args::parse_args(&raw[skip..], ParsingStyle::default()) { + Ok(a) => a, + Err(e) => { + eprintln!("{}", e); + std::process::exit(2); + } + }; args.extra = normalize_extras(args.extra, true)?; @@ -173,38 +250,85 @@ pub fn get_args() -> Result> { if args.update_defaults { args.host = args.host.or(default_host); - if args.core { - args.format = args.format.or(String::from("PSQL")); - } else { - args.format = args.format.or(String::from("TabSeparatedWithNamesAndTypes")); - } - fs::write(&config_path, serde_yaml::to_string(&args)?)?; return Ok(args); } args.verbose = args.verbose || defaults.verbose; - args.concise = args.concise || defaults.concise; args.hide_pii = args.hide_pii || defaults.hide_pii; + // Use defaults for numeric settings if not specified + if args.min_col_width == default_min_col_width() { + args.min_col_width = defaults.min_col_width; + } + if args.max_cell_length == default_max_cell_length() { + args.max_cell_length = defaults.max_cell_length; + } + + args.database = args + .database + .or(args.core.then(|| String::from("firebolt")).unwrap_or(defaults.database)) + .or(String::from("local_dev_db")); + if args.core { args.host = args.host.or(String::from("localhost:3473")); args.jwt = String::from(""); - args.format = args.format.or(String::from("PSQL")); } else { - args.format = args.format.or(defaults.format).or(String::from("PSQL")); args.host = args.host.or(defaults.host).or(default_host); } + // Default to client:auto unless --format was explicitly provided. + args.format = args.format.or(String::from("client:auto")); + if !args.extra.is_empty() { let mut extras = normalize_extras(defaults.extra, true)?; extras.append(&mut args.extra); args.extra = normalize_extras(extras, false)?; } + // Warn if user specified a client format name without the "client:" prefix + if args.format.eq_ignore_ascii_case("auto") + || args.format.eq_ignore_ascii_case("vertical") + || args.format.eq_ignore_ascii_case("horizontal") + { + eprintln!("Warning: Format '{}' is not supported by the server.", args.format); + eprintln!("Did you mean '--format client:{}'?", args.format.to_lowercase()); + eprintln!("Client-side formats require the 'client:' prefix (e.g., client:auto, client:vertical, client:horizontal)"); + eprintln!(); + } + Ok(args) } +/// Infer the JSON type of a query parameter value. +/// +/// Rules (tried in order): +/// - `null` / `NULL` → JSON null +/// - `true` / `false` → JSON boolean +/// - Parses as i64 → JSON integer +/// - Parses as f64 → JSON float +/// - Otherwise → JSON string +fn param_to_json_value(s: &str) -> serde_json::Value { + if s.eq_ignore_ascii_case("null") { + return serde_json::Value::Null; + } + if s.eq_ignore_ascii_case("true") { + return serde_json::Value::Bool(true); + } + if s.eq_ignore_ascii_case("false") { + return serde_json::Value::Bool(false); + } + if let Ok(i) = s.parse::() { + return serde_json::Value::Number(i.into()); + } + if let Ok(f) = s.parse::() { + if let Some(n) = serde_json::Number::from_f64(f) { + return serde_json::Value::Number(n); + } + } + serde_json::Value::String(s.to_string()) +} + // Create URL from Args pub fn get_url(args: &Args) -> String { let query_label = if !args.label.is_empty() && !args.extra.iter().any(|e| e.starts_with("query_label=")) { @@ -228,14 +352,33 @@ pub fn get_url(args: &Args) -> String { let is_localhost = args.host.starts_with("localhost"); let protocol = if is_localhost { "http" } else { "https" }; let output_format = if !args.format.is_empty() && !args.extra.iter().any(|e| e.starts_with("format=")) { - format!("&output_format={}", args.format) + if args.format.starts_with("client:") { + // Client-side rendering: always use JSONLines_Compact + "&output_format=JSONLines_Compact".to_string() + } else { + // Server-side rendering: use format as-is + format!("&output_format={}", &args.format) + } } else { String::new() }; let advanced_mode = if is_localhost { "" } else { "&advanced_mode=1" }; + let query_parameters = if args.params.is_empty() { + String::new() + } else { + let arr: Vec = args.params.iter().enumerate() + .map(|(i, v)| serde_json::json!({ + "name": format!("${}", i + 1), + "value": param_to_json_value(v), + })) + .collect(); + let json = serde_json::to_string(&arr).unwrap_or_default(); + format!("&query_parameters={}", urlencoding::encode(&json)) + }; + format!( - "{protocol}://{host}/?{database}{query_label}{extra}{output_format}{advanced_mode}", + "{protocol}://{host}/?{database}{query_label}{extra}{output_format}{advanced_mode}{query_parameters}", host = args.host ) } @@ -337,4 +480,136 @@ mod tests { assert_eq!(result[1], "param2=value%20with%20spaces"); assert_eq!(result[2], "param3=%20%20value%20with%20spaces%20"); } + + #[test] + fn test_should_render_table_with_client_prefix() { + let mut args = Args::parse_args_default_or_exit(); + + // Server-side format: should not render + args.format = String::from("PSQL"); + assert!(!args.should_render_table()); + + args.format = String::from("JSON"); + assert!(!args.should_render_table()); + + // Client-side format: should render + args.format = String::from("client:auto"); + assert!(args.should_render_table()); + + args.format = String::from("client:vertical"); + assert!(args.should_render_table()); + + args.format = String::from("client:horizontal"); + assert!(args.should_render_table()); + } + + #[test] + fn test_get_display_mode() { + let mut args = Args::parse_args_default_or_exit(); + + // Client formats + args.format = String::from("client:auto"); + assert_eq!(args.get_display_mode(), "auto"); + + args.format = String::from("client:vertical"); + assert_eq!(args.get_display_mode(), "vertical"); + + args.format = String::from("client:horizontal"); + assert_eq!(args.get_display_mode(), "horizontal"); + + // Server formats + args.format = String::from("PSQL"); + assert_eq!(args.get_display_mode(), ""); + + args.format = String::from("JSON"); + assert_eq!(args.get_display_mode(), ""); + } + + #[test] + fn test_display_mode_helpers() { + let mut args = Args::parse_args_default_or_exit(); + + args.format = String::from("client:auto"); + assert!(!args.is_vertical_display()); + assert!(!args.is_horizontal_display()); + + args.format = String::from("client:vertical"); + assert!(args.is_vertical_display()); + assert!(!args.is_horizontal_display()); + + args.format = String::from("client:horizontal"); + assert!(!args.is_vertical_display()); + assert!(args.is_horizontal_display()); + + args.format = String::from("PSQL"); + assert!(!args.is_vertical_display()); + assert!(!args.is_horizontal_display()); + } + + #[test] + fn test_param_to_json_value() { + assert_eq!(param_to_json_value("null"), serde_json::Value::Null); + assert_eq!(param_to_json_value("NULL"), serde_json::Value::Null); + assert_eq!(param_to_json_value("true"), serde_json::Value::Bool(true)); + assert_eq!(param_to_json_value("false"), serde_json::Value::Bool(false)); + assert_eq!(param_to_json_value("42"), serde_json::json!(42i64)); + assert_eq!(param_to_json_value("-7"), serde_json::json!(-7i64)); + assert_eq!(param_to_json_value("3.14"), serde_json::json!(3.14f64)); + assert_eq!(param_to_json_value("hello"), serde_json::json!("hello")); + assert_eq!(param_to_json_value("42abc"), serde_json::json!("42abc")); + } + + #[test] + fn test_get_url_with_params() { + let mut args = Args::parse_args_default_or_exit(); + args.host = "localhost:8123".to_string(); + args.database = "test_db".to_string(); + args.format = "PSQL".to_string(); + args.params = vec!["42".to_string(), "alice".to_string(), "true".to_string()]; + + let url = get_url(&args); + // Should contain URL-encoded JSON array + assert!(url.contains("query_parameters="), "URL should contain query_parameters"); + // Decode and verify content + let encoded = url.split("query_parameters=").nth(1).unwrap().split('&').next().unwrap(); + let decoded = urlencoding::decode(encoded).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&decoded).unwrap(); + let arr = parsed.as_array().unwrap(); + assert_eq!(arr.len(), 3); + assert_eq!(arr[0]["name"], "$1"); + assert_eq!(arr[0]["value"], 42i64); + assert_eq!(arr[1]["name"], "$2"); + assert_eq!(arr[1]["value"], "alice"); + assert_eq!(arr[2]["name"], "$3"); + assert_eq!(arr[2]["value"], true); + } + + #[test] + fn test_get_url_without_params() { + let mut args = Args::parse_args_default_or_exit(); + args.host = "localhost:8123".to_string(); + args.format = "PSQL".to_string(); + + let url = get_url(&args); + assert!(!url.contains("query_parameters=")); + } + + #[test] + fn test_format_without_client_prefix() { + // Test that formats "auto", "vertical", "horizontal" without "client:" prefix + // are recognized (they will trigger a warning at runtime, but are valid format strings) + let mut args = Args::parse_args_default_or_exit(); + + args.format = String::from("auto"); + assert!(!args.should_render_table()); // Should NOT render because no "client:" prefix + assert_eq!(args.get_display_mode(), ""); // Empty because no prefix + + args.format = String::from("vertical"); + assert!(!args.should_render_table()); + assert_eq!(args.get_display_mode(), ""); + + args.format = String::from("horizontal"); + assert!(!args.should_render_table()); + assert_eq!(args.get_display_mode(), ""); + } } diff --git a/src/auth.rs b/src/auth.rs index 43d634c..22cdcc9 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -15,6 +15,7 @@ pub async fn authenticate_service_account(context: &mut Context) -> Result<(), B } } + let show_spinner = !context.is_tui() && context.args.should_render_table(); let args = &mut context.args; if args.sa_id.is_empty() { return Err("Missing Service Account ID (--sa-id)".into()); @@ -72,13 +73,13 @@ pub async fn authenticate_service_account(context: &mut Context) -> Result<(), B let async_resp = async_req.send(); let token = CancellationToken::new(); - let maybe_spin = if args.no_spinner || args.concise { - None - } else { + let maybe_spin = if show_spinner { let token_clone = token.clone(); Some(task::spawn(async { spin(token_clone).await; })) + } else { + None }; let response = async_resp.await; diff --git a/src/completion/candidates.rs b/src/completion/candidates.rs new file mode 100644 index 0000000..6c0640c --- /dev/null +++ b/src/completion/candidates.rs @@ -0,0 +1,534 @@ +/// Shared completion candidate collection used by both tab-completion and fuzzy search. +/// +/// Both completers call `collect_candidates()` to get a unified, consistently-scored +/// list of schema items. Tab completion then applies additional context filters +/// (hide columns from unrelated tables, hide non-public tables from unreferenced schemas). +/// Fuzzy search uses the raw candidates as-is. +use std::sync::Arc; + +use super::schema_cache::SchemaCache; +use super::usage_tracker::{ItemType, UsageTracker}; + +// ── Priority tiers ──────────────────────────────────────────────────────────── +// Higher value → shown first. +// +// Ordering (highest first): +// 5000 Column from a table referenced in the current SQL +// 4500 Public table +// 4400 Schema completion (e.g. "information_schema.") +// 4000 Non-public table +// 3000 Column from a table NOT in the current SQL +// 1000 Function +// 0 System-schema items (information_schema / pg_catalog) +// +// Usage bonus adds up to 990 (99 uses × 10 pts) within each tier. + +/// Column belongs to a table referenced in the current SQL statement. +pub const PRIORITY_COLUMN_IN_QUERY: u32 = 5000; +/// Public-schema table (shown as bare name). +pub const PRIORITY_PUBLIC_TABLE: u32 = 4500; +/// Schema completion (e.g. "information_schema.") — just below public tables. +pub const PRIORITY_SCHEMA: u32 = 4400; +/// Non-public table. +pub const PRIORITY_NONPUBLIC_TABLE: u32 = 4000; +/// Column from a table *not* referenced in the current statement. +pub const PRIORITY_COLUMN_OTHER: u32 = 3000; +/// SQL function. +pub const PRIORITY_FUNCTION: u32 = 1000; +/// Items from system schemas (information_schema, pg_catalog). +/// Must be < PRIORITY_FUNCTION even with max usage bonus (0 + 99*10 = 990 < 1000). +pub const PRIORITY_SYSTEM: u32 = 0; + +const MAX_USAGE: u32 = 99; +const USAGE_MULTIPLIER: u32 = 10; + +// ── Candidate ───────────────────────────────────────────────────────────────── + +/// A single completion candidate produced by `collect_candidates`. +#[derive(Debug, Clone)] +pub struct Candidate { + /// Text shown in the popup and used as the default insert value. + pub display: String, + /// Text actually inserted when the candidate is accepted. + /// Differs from `display` for functions: display = "fn()", insert = "fn(". + pub insert: String, + /// Short type label: "table", "column", "function", or "schema". + pub description: &'static str, + /// Logical type — used for selectivity filtering. + pub item_type: ItemType, + /// Schema name; empty string for public-schema items and functions. + pub schema: String, + /// Table that owns this column (Column items only). + pub table_name: Option, + /// Alternative names tried for prefix matching in addition to `display`. + /// + /// Examples: + /// - non-public table `info.engine_qh` → alts = `["engine_qh"]` + /// - public column `test.val` → alts = `["val"]` + /// - non-public col `info.engine_qh.t` → alts = `["engine_qh.t", "t"]` + pub alts: Vec, + /// Priority score; higher items appear first. Includes usage bonus. + pub priority: u32, +} + +impl Candidate { + /// Returns `true` if `partial` is a prefix of `display` or any alternative name. + /// Empty partial always matches (shows all candidates). + pub fn prefix_matches(&self, partial: &str) -> bool { + if partial.is_empty() { + return true; + } + let p = partial.to_lowercase(); + if self.display.to_lowercase().starts_with(&p) { + return true; + } + self.alts.iter().any(|a| a.to_lowercase().starts_with(&p)) + } +} + +// ── Query-context helpers ───────────────────────────────────────────────────── + +/// Returns `true` if the given table (identified by `table_name` + `schema`) +/// is referenced anywhere in `query_tables` (the list of tables extracted from +/// the current SQL statement by `ContextAnalyzer::extract_tables`). +/// +/// Matches bare name, schema-qualified name, and cross-qualified variations. +pub fn table_in_query(table_name: &str, schema: &str, query_tables: &[String]) -> bool { + if query_tables.is_empty() { + return false; + } + let t = table_name.to_lowercase(); + let s = schema.to_lowercase(); + let qualified = format!("{}.{}", s, t); + + query_tables.iter().any(|qt| { + let q = qt.to_lowercase(); + q == t // bare match ("users" == "users") + || q == qualified // full match ("public.users" == "public.users") + || q.ends_with(&format!(".{}", t)) // query "public.users", cache bare "users" + || qualified == q // same as above but other way + }) +} + +/// Returns `true` if the given schema name is referenced in `query_tables`. +/// +/// Always returns `true` for the public schema (empty string or "public") because +/// public tables are always visible in SQL without schema qualification. +pub fn schema_in_query(schema: &str, query_tables: &[String]) -> bool { + if schema == "public" || schema.is_empty() { + return true; + } + if query_tables.is_empty() { + return false; + } + let s = schema.to_lowercase(); + query_tables.iter().any(|qt| { + let q = qt.to_lowercase(); + q.starts_with(&format!("{}.", s)) || q == s + }) +} + +// ── Candidate collection ────────────────────────────────────────────────────── + +/// Collect all completion candidates from the schema cache with priority scores. +/// +/// Pass `tables_in_query` (from `ContextAnalyzer::extract_tables`) to get +/// context-aware column priority. Fuzzy search passes `&[]` (no context). +pub fn collect_candidates( + cache: &SchemaCache, + usage_tracker: Arc, + tables_in_query: &[String], +) -> Vec { + let mut items = Vec::new(); + + // ── Tables ─────────────────────────────────────────────────────────────── + let mut seen_schemas: std::collections::HashSet = std::collections::HashSet::new(); + + for (schema, table) in cache.get_all_tables() { + let is_public = schema == "public" || schema.is_empty(); + let is_system = is_system_schema(&schema); + + let (display, insert) = if is_public { + (table.clone(), table.clone()) + } else { + let q = format!("{}.{}", schema, table); + (q.clone(), q) + }; + + // Non-public tables expose the bare table name as an alternative so + // the user can type e.g. "engine_q" and still see + // "information_schema.engine_query_history". + let alts: Vec = if is_public { vec![] } else { vec![table.clone()] }; + + let usage_count = usage_tracker.get_count(ItemType::Table, &table); + let usage_bonus = usage_count.min(MAX_USAGE) * USAGE_MULTIPLIER; + + let base = if is_system { + PRIORITY_SYSTEM + } else if is_public { + PRIORITY_PUBLIC_TABLE + } else { + PRIORITY_NONPUBLIC_TABLE + }; + + items.push(Candidate { + display, + insert, + description: "table", + item_type: ItemType::Table, + schema: schema.clone(), + table_name: None, + alts, + priority: base.saturating_add(usage_bonus), + }); + + if !is_public && !schema.is_empty() { + seen_schemas.insert(schema); + } + } + + // ── Schemas ────────────────────────────────────────────────────────────── + // Emit "schema." completions so the user can drill into a schema. + let mut schema_list: Vec = seen_schemas.into_iter().collect(); + schema_list.sort(); + + for schema in schema_list { + let schema_with_dot = format!("{}.", schema); + let usage_count = usage_tracker.get_count(ItemType::Table, &schema); + let usage_bonus = usage_count.min(MAX_USAGE) * USAGE_MULTIPLIER; + let base = if is_system_schema(&schema) { PRIORITY_SYSTEM } else { PRIORITY_SCHEMA }; + + items.push(Candidate { + display: schema_with_dot.clone(), + insert: schema_with_dot, + description: "schema", + item_type: ItemType::Schema, + schema: schema.clone(), + table_name: None, + alts: vec![], + priority: base.saturating_add(usage_bonus), + }); + } + + // ── Columns ────────────────────────────────────────────────────────────── + for (schema, table, column) in cache.get_all_columns() { + let is_public = schema == "public" || schema.is_empty(); + let is_system = is_system_schema(&schema); + + // Always emit qualified names: "table.col" or "schema.table.col" + let display = if is_public { + format!("{}.{}", table, column) + } else { + format!("{}.{}.{}", schema, table, column) + }; + + // Alternative prefix matches: + // public: "col" (bare column name) + // non-public: "table.col", "col" (also allows "table" prefix to trigger) + let alts: Vec = if is_public { + vec![column.clone()] + } else { + vec![format!("{}.{}", table, column), column.clone()] + }; + + let in_query = + !tables_in_query.is_empty() && table_in_query(&table, &schema, tables_in_query); + + // in_query always takes precedence: if the user explicitly referenced this + // table in the current SQL, its columns should be shown at top priority + // regardless of whether the schema is a system schema. + let base = if in_query { + PRIORITY_COLUMN_IN_QUERY + } else if is_system { + PRIORITY_SYSTEM + } else { + PRIORITY_COLUMN_OTHER + }; + + let usage_count = usage_tracker.get_count(ItemType::Column, &column); + let usage_bonus = usage_count.min(MAX_USAGE) * USAGE_MULTIPLIER; + + items.push(Candidate { + display: display.clone(), + insert: display, + description: "column", + item_type: ItemType::Column, + schema: schema.clone(), + table_name: Some(table), + alts, + priority: base.saturating_add(usage_bonus), + }); + } + + // ── Functions ───────────────────────────────────────────────────────────── + for function in cache.get_all_functions() { + let usage_count = usage_tracker.get_count(ItemType::Function, &function); + let usage_bonus = usage_count.min(MAX_USAGE) * USAGE_MULTIPLIER; + + // Handle the "name-distinct" pattern: e.g. "count-distinct" → + // display as "count-distinct()" but insert "count(DISTINCT ". + let (display, insert, alts) = if let Some(base) = function.strip_suffix("-distinct") { + ( + format!("{}-distinct()", base), + format!("{}(DISTINCT ", base), + vec![function.clone(), base.to_string(), format!("{}_distinct", base)], + ) + } else { + ( + format!("{}()", function), + format!("{}(", function), + vec![function.clone()], + ) + }; + + items.push(Candidate { + display, + insert, + description: "function", + item_type: ItemType::Function, + schema: String::new(), + table_name: None, + alts, + priority: PRIORITY_FUNCTION.saturating_add(usage_bonus), + }); + } + + items +} + +fn is_system_schema(schema: &str) -> bool { + schema == "information_schema" || schema == "pg_catalog" +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + // ── table_in_query ──────────────────────────────────────────────────────── + + #[test] + fn test_table_in_query_bare_match() { + let q = &["users".to_string()]; + assert!(table_in_query("users", "public", q)); + assert!(!table_in_query("orders", "public", q)); + } + + #[test] + fn test_table_in_query_qualified_in_sql() { + // SQL has "information_schema.engine_query_history", cache has bare table name + let q = &["information_schema.engine_query_history".to_string()]; + assert!(table_in_query( + "engine_query_history", + "information_schema", + q + )); + assert!(!table_in_query("columns", "information_schema", q)); + } + + #[test] + fn test_table_in_query_cross_qualified() { + // SQL has "public.users", cache table bare "users" + let q1 = &["public.users".to_string()]; + assert!(table_in_query("users", "public", q1)); + + // SQL has bare "users", cache "public.users" + let q2 = &["users".to_string()]; + assert!(table_in_query("users", "public", q2)); + } + + #[test] + fn test_table_in_query_empty_query_tables() { + assert!(!table_in_query("users", "public", &[])); + } + + // ── schema_in_query ─────────────────────────────────────────────────────── + + #[test] + fn test_schema_in_query_public_always_visible() { + assert!(schema_in_query("public", &[])); + assert!(schema_in_query("", &[])); + assert!(schema_in_query("public", &["orders".to_string()])); + } + + #[test] + fn test_schema_in_query_nonpublic_found() { + let q = &["information_schema.columns".to_string()]; + assert!(schema_in_query("information_schema", q)); + } + + #[test] + fn test_schema_in_query_nonpublic_not_found() { + let q = &["orders".to_string()]; + assert!(!schema_in_query("information_schema", q)); + assert!(!schema_in_query("pg_catalog", q)); + } + + #[test] + fn test_schema_in_query_empty_query_tables() { + assert!(!schema_in_query("information_schema", &[])); + } + + // ── Candidate::prefix_matches ───────────────────────────────────────────── + + #[test] + fn test_prefix_matches_empty_always_true() { + let c = make_column_candidate( + "information_schema", + "engine_query_history", + "start_time", + ); + assert!(c.prefix_matches("")); + } + + #[test] + fn test_prefix_matches_via_display() { + let c = make_column_candidate("public", "orders", "order_id"); + // Public column display = "orders.order_id" + assert!(c.prefix_matches("orders.ord")); + assert!(!c.prefix_matches("users.order")); + } + + #[test] + fn test_prefix_matches_via_bare_col() { + let c = make_column_candidate("public", "orders", "order_id"); + // Alt = "order_id" + assert!(c.prefix_matches("order")); + assert!(!c.prefix_matches("zzzz")); + } + + #[test] + fn test_prefix_matches_nonpublic_via_table_col() { + let c = make_column_candidate( + "information_schema", + "engine_query_history", + "start_time", + ); + // display = "information_schema.engine_query_history.start_time" + // alts = ["engine_query_history.start_time", "start_time"] + assert!(c.prefix_matches("information_schema.engine")); + assert!(c.prefix_matches("engine_query")); // via alt "engine_query_history.start_time" + assert!(c.prefix_matches("start")); // via alt "start_time" + assert!(!c.prefix_matches("nonexistent")); + } + + #[test] + fn test_prefix_matches_nonpublic_table() { + let c = make_table_candidate("information_schema", "columns"); + // display = "information_schema.columns" + // alts = ["columns"] + assert!(c.prefix_matches("information_schema")); + assert!(c.prefix_matches("col")); // via alt + assert!(!c.prefix_matches("zzz")); + } + + // ── collect_candidates priorities ──────────────────────────────────────── + + #[test] + fn test_collect_priority_column_in_query_beats_table() { + let _cache = Arc::new(super::super::schema_cache::SchemaCache::new(300)); + let _usage_tracker = Arc::new(UsageTracker::new(10)); + // Empty cache — just verify priority constants are ordered correctly + assert!(PRIORITY_COLUMN_IN_QUERY > PRIORITY_PUBLIC_TABLE); + assert!(PRIORITY_PUBLIC_TABLE > PRIORITY_SCHEMA); + assert!(PRIORITY_SCHEMA > PRIORITY_NONPUBLIC_TABLE); + assert!(PRIORITY_NONPUBLIC_TABLE > PRIORITY_COLUMN_OTHER); + assert!(PRIORITY_COLUMN_OTHER > PRIORITY_FUNCTION); + assert!(PRIORITY_FUNCTION > PRIORITY_SYSTEM); + } + + /// Columns from a system-schema table that is referenced in the current SQL + /// must get PRIORITY_COLUMN_IN_QUERY, not PRIORITY_SYSTEM. + /// + /// Regression test: previously `is_system` was checked before `in_query`, + /// burying information_schema columns at priority 0 even when the user had + /// written `FROM information_schema.engine_query_history`. + #[test] + fn test_system_schema_column_in_query_gets_high_priority() { + // Create a candidate as collect_candidates would + let tables_in_query = vec!["information_schema.engine_query_history".to_string()]; + let in_query = table_in_query( + "engine_query_history", + "information_schema", + &tables_in_query, + ); + assert!(in_query, "table_in_query should match qualified reference"); + + // The priority base should be COLUMN_IN_QUERY, not SYSTEM + let base = if in_query { + PRIORITY_COLUMN_IN_QUERY + } else if is_system_schema_test("information_schema") { + PRIORITY_SYSTEM + } else { + PRIORITY_COLUMN_OTHER + }; + assert_eq!( + base, PRIORITY_COLUMN_IN_QUERY, + "system-schema column from query table must get PRIORITY_COLUMN_IN_QUERY" + ); + } + + fn is_system_schema_test(s: &str) -> bool { + s == "information_schema" || s == "pg_catalog" + } + + #[test] + fn test_collect_system_schema_priority() { + // usage bonus cannot lift a system item above PRIORITY_FUNCTION + let max_system = PRIORITY_SYSTEM + MAX_USAGE * USAGE_MULTIPLIER; + assert!( + max_system < PRIORITY_FUNCTION, + "max_system={} must be < PRIORITY_FUNCTION={}", + max_system, + PRIORITY_FUNCTION + ); + } + + // ── helper constructors for tests ───────────────────────────────────────── + + fn make_column_candidate(schema: &str, table: &str, column: &str) -> Candidate { + let is_public = schema == "public" || schema.is_empty(); + let display = if is_public { + format!("{}.{}", table, column) + } else { + format!("{}.{}.{}", schema, table, column) + }; + let alts: Vec = if is_public { + vec![column.to_string()] + } else { + vec![format!("{}.{}", table, column), column.to_string()] + }; + Candidate { + display: display.clone(), + insert: display, + description: "column", + item_type: ItemType::Column, + schema: schema.to_string(), + table_name: Some(table.to_string()), + alts, + priority: PRIORITY_COLUMN_IN_QUERY, + } + } + + fn make_table_candidate(schema: &str, table: &str) -> Candidate { + let is_public = schema == "public" || schema.is_empty(); + let display = if is_public { + table.to_string() + } else { + format!("{}.{}", schema, table) + }; + let alts: Vec = if is_public { vec![] } else { vec![table.to_string()] }; + Candidate { + display: display.clone(), + insert: display, + description: "table", + item_type: ItemType::Table, + schema: schema.to_string(), + table_name: None, + alts, + priority: PRIORITY_NONPUBLIC_TABLE, + } + } +} diff --git a/src/completion/context_analyzer.rs b/src/completion/context_analyzer.rs new file mode 100644 index 0000000..5452509 --- /dev/null +++ b/src/completion/context_analyzer.rs @@ -0,0 +1,169 @@ +/// Analyzes SQL context to extract table names and identify system schemas +pub struct ContextAnalyzer; + +impl ContextAnalyzer { + /// Extract table names from the current SQL statement + /// Looks for patterns like "FROM table", "JOIN table", "UPDATE table" + pub fn extract_tables(sql: &str) -> Vec { + let mut tables = Vec::new(); + let sql_upper = sql.to_uppercase(); + + // Pattern: FROM table_name + if let Some(from_pos) = sql_upper.find("FROM") { + let after_from = &sql[from_pos + 4..]; + if let Some(word) = Self::extract_first_identifier(after_from) { + tables.push(word); + } + } + + // Pattern: JOIN table_name + for (idx, _) in sql_upper.match_indices("JOIN") { + let after_join = &sql[idx + 4..]; + if let Some(word) = Self::extract_first_identifier(after_join) { + tables.push(word); + } + } + + // Pattern: UPDATE table_name + if let Some(update_pos) = sql_upper.find("UPDATE") { + let after_update = &sql[update_pos + 6..]; + if let Some(word) = Self::extract_first_identifier(after_update) { + tables.push(word); + } + } + + // Pattern: INTO table_name (for INSERT statements) + if let Some(into_pos) = sql_upper.find("INTO") { + let after_into = &sql[into_pos + 4..]; + if let Some(word) = Self::extract_first_identifier(after_into) { + tables.push(word); + } + } + + tables + } + + /// Check if a qualified name belongs to a system schema + pub fn is_system_schema(qualified_name: &str) -> bool { + qualified_name.starts_with("information_schema.") + || qualified_name.starts_with("pg_catalog.") + || qualified_name == "information_schema" + || qualified_name == "pg_catalog" + } + + /// Extract the first SQL identifier from text + /// Handles schema-qualified names (e.g., "public.users") + fn extract_first_identifier(text: &str) -> Option { + let trimmed = text.trim(); + let mut identifier = String::new(); + + for ch in trimmed.chars() { + if ch.is_alphanumeric() || ch == '_' || ch == '.' { + identifier.push(ch); + } else if !identifier.is_empty() { + // Stop at first non-identifier character + break; + } + } + + if identifier.is_empty() { + None + } else { + Some(identifier) + } + } + + #[allow(dead_code)] + /// Extract the table name from a qualified column reference + /// E.g., "users.user_id" -> Some("users") + pub fn extract_table_from_qualified_column(qualified_column: &str) -> Option { + if let Some(dot_pos) = qualified_column.rfind('.') { + Some(qualified_column[..dot_pos].to_string()) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_tables_from_simple_query() { + let tables = ContextAnalyzer::extract_tables("SELECT * FROM users"); + assert_eq!(tables, vec!["users"]); + } + + #[test] + fn test_extract_tables_from_join() { + let tables = ContextAnalyzer::extract_tables( + "SELECT * FROM users JOIN orders ON users.id = orders.user_id" + ); + assert_eq!(tables.len(), 2); + assert!(tables.contains(&"users".to_string())); + assert!(tables.contains(&"orders".to_string())); + } + + #[test] + fn test_extract_tables_with_schema() { + let tables = ContextAnalyzer::extract_tables("SELECT * FROM public.users"); + assert_eq!(tables, vec!["public.users"]); + } + + #[test] + fn test_extract_tables_from_update() { + let tables = ContextAnalyzer::extract_tables("UPDATE users SET active = true"); + assert_eq!(tables, vec!["users"]); + } + + #[test] + fn test_extract_tables_from_insert() { + let tables = ContextAnalyzer::extract_tables("INSERT INTO users (name) VALUES ('Alice')"); + assert_eq!(tables, vec!["users"]); + } + + #[test] + fn test_is_system_schema() { + assert!(ContextAnalyzer::is_system_schema("information_schema.tables")); + assert!(ContextAnalyzer::is_system_schema("pg_catalog.pg_class")); + assert!(ContextAnalyzer::is_system_schema("information_schema")); + assert!(ContextAnalyzer::is_system_schema("pg_catalog")); + + assert!(!ContextAnalyzer::is_system_schema("public.users")); + assert!(!ContextAnalyzer::is_system_schema("users")); + assert!(!ContextAnalyzer::is_system_schema("my_schema.table")); + } + + #[test] + fn test_extract_table_from_qualified_column() { + assert_eq!( + ContextAnalyzer::extract_table_from_qualified_column("users.user_id"), + Some("users".to_string()) + ); + assert_eq!( + ContextAnalyzer::extract_table_from_qualified_column("public.users.user_id"), + Some("public.users".to_string()) + ); + assert_eq!( + ContextAnalyzer::extract_table_from_qualified_column("user_id"), + None + ); + } + + #[test] + fn test_extract_first_identifier() { + assert_eq!( + ContextAnalyzer::extract_first_identifier(" users "), + Some("users".to_string()) + ); + assert_eq!( + ContextAnalyzer::extract_first_identifier("public.users WHERE"), + Some("public.users".to_string()) + ); + assert_eq!( + ContextAnalyzer::extract_first_identifier("users,"), + Some("users".to_string()) + ); + } +} diff --git a/src/completion/context_detector.rs b/src/completion/context_detector.rs new file mode 100644 index 0000000..43e9a49 --- /dev/null +++ b/src/completion/context_detector.rs @@ -0,0 +1,32 @@ +/// Finds the start position of the word at the given position +pub fn find_word_start(line: &str, pos: usize) -> usize { + let bytes = line.as_bytes(); + let mut start = pos; + + while start > 0 { + let prev = start - 1; + let ch = bytes[prev] as char; + + // Word characters: alphanumeric, underscore, or dot (for schema.table) + if ch.is_alphanumeric() || ch == '_' || ch == '.' { + start = prev; + } else { + break; + } + } + + start +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_find_word_start() { + assert_eq!(find_word_start("SELECT * FROM users", 19), 14); + assert_eq!(find_word_start("SELECT col", 10), 7); + assert_eq!(find_word_start("public.users", 12), 0); + assert_eq!(find_word_start("SELECT * FROM public.us", 23), 14); + } +} diff --git a/src/completion/fuzzy_completer.rs b/src/completion/fuzzy_completer.rs new file mode 100644 index 0000000..881aaa6 --- /dev/null +++ b/src/completion/fuzzy_completer.rs @@ -0,0 +1,98 @@ +/// Fuzzy schema search across all tables, columns, and functions. +/// +/// Used by the Ctrl+Space overlay in the TUI. Collects all items from the +/// schema cache via the shared `collect_candidates()` function and applies +/// nucleo fuzzy matching to rank them. +use nucleo_matcher::{ + pattern::{CaseMatching, Normalization, Pattern}, + Config, Matcher, Utf32Str, +}; +use std::sync::Arc; + +use super::candidates::{self, collect_candidates}; +use super::schema_cache::SchemaCache; +use super::usage_tracker::UsageTracker; + +pub struct FuzzyCompleter { + cache: Arc, + usage_tracker: Arc, + matcher: Matcher, +} + +impl FuzzyCompleter { + pub fn new(cache: Arc, usage_tracker: Arc) -> Self { + Self { + cache, + usage_tracker, + matcher: Matcher::new(Config::DEFAULT), + } + } + + /// Return all schema items that fuzzy-match `query`, ranked by score then priority. + /// + /// `tables_in_query` — tables extracted from the current SQL statement + /// (via `ContextAnalyzer::extract_tables`). Used to give context-aware + /// priority boosts to columns whose tables are already referenced, matching + /// the behaviour of tab completion. Pass `&[]` when no context is available. + /// + /// Returns up to `limit` items. + pub fn search(&mut self, query: &str, limit: usize, tables_in_query: &[String]) -> Vec { + let all = collect_candidates(&self.cache, self.usage_tracker.clone(), tables_in_query); + + if query.is_empty() { + let mut items: Vec = all.into_iter().map(candidate_to_fuzzy).collect(); + items.sort_by(|a, b| { + b.priority + .cmp(&a.priority) + .then(a.label.cmp(&b.label)) + }); + items.truncate(limit); + return items; + } + + let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart); + + let mut scored: Vec<(u32, FuzzyItem)> = all + .into_iter() + .filter_map(|item| { + let fi = candidate_to_fuzzy(item); + // Run fuzzy match against the display label + let haystack = Utf32Str::Ascii(fi.label.as_bytes()); + pattern + .score(haystack, &mut self.matcher) + .map(|s| (s, fi)) + }) + .collect(); + + scored.sort_by(|a, b| { + b.0.cmp(&a.0) + .then(b.1.priority.cmp(&a.1.priority)) + .then(a.1.label.cmp(&b.1.label)) + }); + scored.truncate(limit); + scored.into_iter().map(|(_, item)| item).collect() + } +} + +fn candidate_to_fuzzy(c: candidates::Candidate) -> FuzzyItem { + FuzzyItem { + label: c.display, + description: c.description.to_string(), + insert_value: c.insert, + priority: c.priority, + } +} + +/// A single item returned by the fuzzy search. +#[derive(Clone, Debug)] +pub struct FuzzyItem { + /// Displayed text in the result list. + pub label: String, + /// Short description matching tab-completion labels: "table" / "column" / "function" / "schema". + pub description: String, + /// Text to insert into the textarea when accepted. + pub insert_value: String, + /// Priority tier used as a tiebreaker when fuzzy scores are equal. + /// Higher = shown first. Derived from the shared priority constants. + pub priority: u32, +} diff --git a/src/completion/mod.rs b/src/completion/mod.rs new file mode 100644 index 0000000..865e233 --- /dev/null +++ b/src/completion/mod.rs @@ -0,0 +1,491 @@ +pub mod candidates; +pub mod context_analyzer; +pub mod context_detector; +pub mod fuzzy_completer; +pub mod priority_scorer; +pub mod schema_cache; +pub mod usage_tracker; + +use candidates::{collect_candidates, schema_in_query, table_in_query}; +use context_analyzer::ContextAnalyzer; +use context_detector::find_word_start; +use priority_scorer::{PriorityScorer, ScoredSuggestion}; +use schema_cache::SchemaCache; +use usage_tracker::{ItemType, UsageTracker}; +use std::sync::Arc; + +/// A single completion candidate with display metadata. +#[derive(Debug, Clone)] +pub struct CompletionItem { + /// The text to insert when accepted. + pub value: String, + /// Short description shown in the popup (type label or schema name). + pub description: String, + /// The logical type of the item. + #[allow(dead_code)] + pub item_type: ItemType, +} + +pub struct SqlCompleter { + cache: Arc, + usage_tracker: Arc, + scorer: PriorityScorer, + enabled: bool, +} + +impl SqlCompleter { + pub fn new(cache: Arc, usage_tracker: Arc, enabled: bool) -> Self { + let scorer = PriorityScorer::new(usage_tracker.clone()); + Self { + cache, + usage_tracker, + scorer, + enabled, + } + } + + pub fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + } + + #[allow(dead_code)] + pub fn is_enabled(&self) -> bool { + self.enabled + } + + #[allow(dead_code)] + pub fn cache(&self) -> &Arc { + &self.cache + } + + /// Return a list of completion candidates for the given input line and cursor position. + /// + /// `line` — the current line up to the cursor (used for word boundary detection). + /// `pos` — cursor byte offset within `line`. + /// `full_sql` — the entire textarea content joined with `\n`; used for table context + /// extraction so tables on other lines are visible to the completer. + /// + /// Returns `(word_start, items)` where `word_start` is the byte offset of the + /// start of the word being completed. + pub fn complete_at(&self, line: &str, pos: usize, full_sql: &str) -> (usize, Vec) { + if !self.enabled { + return (0, Vec::::new()); + } + + // Find the start of the word we're completing + let word_start = find_word_start(line, pos); + let partial = &line[word_start..pos]; + + // Extract context from the full SQL so tables on other lines are visible. + let tables_in_line = ContextAnalyzer::extract_tables(full_sql); + + // ── Dot-mode: partial already contains a dot ────────────────────────── + // + // Two sub-cases: + // (a) "table." → user explicitly requests columns of that table + // (b) "schema.tab" → user navigates into a schema to pick a table + // + // We detect (a) by checking whether the part before the dot is a table + // referenced in the current SQL. Otherwise we fall through to (b). + if let Some(dot_pos) = partial.rfind('.') { + let schema_part = &partial[..dot_pos]; + let table_part = &partial[dot_pos + 1..]; + + let schema_lower = schema_part.to_lowercase(); + let col_prefix_lower = table_part.to_lowercase(); + + // Check if the part before the dot is a query-referenced table + let is_query_table = tables_in_line.iter().any(|t| { + let t_lower = t.to_lowercase(); + t_lower == schema_lower + || t_lower.ends_with(&format!(".{}", schema_lower)) + || schema_lower.ends_with(&format!(".{}", t_lower)) + }); + + let mut scored: Vec = Vec::new(); + + if is_query_table { + // (a) Emit columns of this table whose name starts with the column prefix + let column_metadata = self.cache.get_columns_with_table(partial); + for (table, column) in column_metadata { + if let Some(tbl) = table { + if tbl.to_lowercase() == schema_lower + && column.to_lowercase().starts_with(&col_prefix_lower) + { + let qualified_name = format!("{}.{}", tbl, column); + let score = self.scorer.score( + &qualified_name, + ItemType::Column, + &tables_in_line, + Some(&tbl), + ); + scored.push(ScoredSuggestion { + name: qualified_name, + item_type: ItemType::Column, + score, + }); + } + } + } + } else { + // (b) Emit tables within the named schema + let tables_in_schema = + self.cache.get_tables_in_schema(schema_part, table_part); + for table in tables_in_schema { + let qualified_name = format!("{}.{}", schema_part, table); + let score = + self.scorer.score(&table, ItemType::Table, &tables_in_line, None); + scored.push(ScoredSuggestion { + name: qualified_name, + item_type: ItemType::Table, + score, + }); + } + } + + scored.sort_by(|a, b| b.score.cmp(&a.score).then(a.name.cmp(&b.name))); + let mut seen = std::collections::HashSet::new(); + let items: Vec = scored + .into_iter() + .filter(|s| seen.insert(s.name.clone())) + .map(|s| CompletionItem { + value: s.name, + description: match s.item_type { + ItemType::Column => "column", + ItemType::Table => "table", + ItemType::Function => "function", + ItemType::Schema => "schema", + } + .to_string(), + item_type: s.item_type, + }) + .collect(); + + return (word_start, items); + } + + // ── No-dot mode: unified candidate collection + tab selectivity ──────── + // + // Collect all schema items with context-aware priority, then apply + // tab-completion's extra selectivity rules: + // • Columns — only if their table appears in the current SQL. + // • Non-public tables — only if their schema appears in the current + // SQL *or* the user is explicitly typing the schema name as a prefix. + // • Schema completions (e.g. "information_schema.") — only when the + // partial does not already contain a dot (suppressed above). + + let all = collect_candidates(&self.cache, self.usage_tracker.clone(), &tables_in_line); + let partial_lower = partial.to_lowercase(); + + let mut filtered: Vec<&candidates::Candidate> = all + .iter() + // ── prefix match ────────────────────────────────────────────────── + .filter(|c| c.prefix_matches(partial)) + // ── tab-specific selectivity ────────────────────────────────────── + .filter(|c| match c.item_type { + ItemType::Column => { + // Show only columns whose table appears in the current SQL. + let table = c.table_name.as_deref().unwrap_or(""); + table_in_query(table, &c.schema, &tables_in_line) + } + ItemType::Table => { + // Public tables: always visible. + // Non-public tables: + // • non-empty partial → always show when prefix matches + // (user is deliberately typing toward this table) + // • empty partial → only show if the schema is already + // referenced in the current SQL (avoids flooding the + // list with dozens of system-schema tables on Tab alone) + c.schema == "public" + || c.schema.is_empty() + || !partial.is_empty() + || schema_in_query(&c.schema, &tables_in_line) + } + ItemType::Schema => { + // Show schema completions only when: + // (a) no dot has been typed yet (dot-mode handles the rest), AND + // (b) the user is explicitly typing the schema name prefix, OR + // the schema is already referenced somewhere in the SQL. + !partial_lower.contains('.') + && (!partial.is_empty() + || schema_in_query(&c.schema, &tables_in_line)) + } + ItemType::Function => true, + }) + .collect(); + + // Sort by priority descending, then alphabetically as tiebreaker + filtered.sort_by(|a, b| { + b.priority + .cmp(&a.priority) + .then(a.display.cmp(&b.display)) + }); + + // Deduplicate (keep highest-priority occurrence) + let mut seen = std::collections::HashSet::new(); + let items: Vec = filtered + .into_iter() + .filter(|c| seen.insert(c.display.clone())) + .map(|c| CompletionItem { + // For columns: insert only the bare column name (last component + // of the qualified display name). The table is already in the + // query, so there is no need to repeat it on every column reference. + // Other types keep their full insert value. + value: if c.item_type == ItemType::Column { + c.display + .rsplit('.') + .next() + .unwrap_or(&c.insert) + .to_string() + } else { + c.insert.clone() + }, + description: c.description.to_string(), + item_type: c.item_type, + }) + .collect(); + + (word_start, items) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_completer_disabled() { + let cache = Arc::new(SchemaCache::new(300)); + let usage_tracker = Arc::new(UsageTracker::new(10)); + let completer = SqlCompleter::new(cache, usage_tracker, false); + + let (_, candidates) = completer.complete_at("SELECT * FROM us", 17, "SELECT * FROM us"); + assert_eq!(candidates.len(), 0); + } + + #[test] + fn test_completer_no_keywords() { + let cache = Arc::new(SchemaCache::new(300)); + let usage_tracker = Arc::new(UsageTracker::new(10)); + let completer = SqlCompleter::new(cache, usage_tracker, true); + + let (_, candidates) = completer.complete_at("SEL", 3, "SEL"); + + // Should not return keywords (only tables and columns from schema cache) + assert!(!candidates.iter().any(|c| c.value == "SELECT")); + } + + // ── Tab selectivity: columns ────────────────────────────────────────────── + + /// Columns from tables NOT in the current SQL must be hidden. + #[test] + fn test_tab_hides_columns_from_unrelated_tables() { + use schema_cache::{ColumnMetadata, TableMetadata}; + + let cache = Arc::new(SchemaCache::new(300)); + cache.inject_test_tables(vec![ + TableMetadata { + schema_name: "public".to_string(), + table_name: "orders".to_string(), + columns: vec![ColumnMetadata { + name: "order_id".to_string(), + data_type: "integer".to_string(), + }], + }, + TableMetadata { + schema_name: "public".to_string(), + table_name: "users".to_string(), + columns: vec![ColumnMetadata { + name: "user_id".to_string(), + data_type: "integer".to_string(), + }], + }, + ]); + let usage_tracker = Arc::new(UsageTracker::new(10)); + let completer = SqlCompleter::new(cache, usage_tracker, true); + + // SQL references "orders" but NOT "users" + let (_, cands) = completer.complete_at("SELECT ord FROM orders", 10, "SELECT ord FROM orders"); + + let values: Vec<&str> = cands.iter().map(|c| c.value.as_str()).collect(); + assert!( + values.iter().any(|v| v.contains("order_id")), + "should suggest orders.order_id" + ); + assert!( + !values.iter().any(|v| v.contains("user_id")), + "should NOT suggest user_id (users table not in SQL)" + ); + } + + /// Columns from the referenced table should be suggested. + #[test] + fn test_tab_shows_columns_for_referenced_table() { + use schema_cache::{ColumnMetadata, TableMetadata}; + + let cache = Arc::new(SchemaCache::new(300)); + cache.inject_test_tables(vec![TableMetadata { + schema_name: "public".to_string(), + table_name: "users".to_string(), + columns: vec![ + ColumnMetadata { + name: "user_id".to_string(), + data_type: "integer".to_string(), + }, + ColumnMetadata { + name: "username".to_string(), + data_type: "text".to_string(), + }, + ], + }]); + let usage_tracker = Arc::new(UsageTracker::new(10)); + let completer = SqlCompleter::new(cache, usage_tracker, true); + + // Empty partial after the table reference + let (_, cands) = completer.complete_at("SELECT FROM users", 7, "SELECT FROM users"); + let values: Vec<&str> = cands.iter().map(|c| c.value.as_str()).collect(); + assert!(values.iter().any(|v| v.contains("user_id"))); + assert!(values.iter().any(|v| v.contains("username"))); + } + + // ── Tab selectivity: non-public tables ──────────────────────────────────── + + /// Non-public tables must be hidden when their schema is not in the SQL. + #[test] + fn test_tab_hides_nonpublic_table_when_schema_not_in_sql() { + use schema_cache::TableMetadata; + + let cache = Arc::new(SchemaCache::new(300)); + cache.inject_test_tables(vec![ + TableMetadata { + schema_name: "public".to_string(), + table_name: "orders".to_string(), + columns: vec![], + }, + TableMetadata { + schema_name: "analytics".to_string(), + table_name: "events".to_string(), + columns: vec![], + }, + ]); + let usage_tracker = Arc::new(UsageTracker::new(10)); + let completer = SqlCompleter::new(cache, usage_tracker, true); + + // SQL does not mention "analytics" schema + let (_, cands) = completer.complete_at("SELECT FROM orders", 7, "SELECT FROM orders"); + let values: Vec<&str> = cands.iter().map(|c| c.value.as_str()).collect(); + assert!( + !values.iter().any(|v| v.contains("analytics")), + "should NOT show analytics.events when analytics is not in SQL" + ); + assert!( + values.iter().any(|v| *v == "orders"), + "should still show public table" + ); + } + + /// Non-public tables must be visible when their schema appears in the SQL. + #[test] + fn test_tab_shows_nonpublic_table_when_schema_in_sql() { + use schema_cache::TableMetadata; + + let cache = Arc::new(SchemaCache::new(300)); + cache.inject_test_tables(vec![TableMetadata { + schema_name: "analytics".to_string(), + table_name: "events".to_string(), + columns: vec![], + }]); + let usage_tracker = Arc::new(UsageTracker::new(10)); + let completer = SqlCompleter::new(cache, usage_tracker, true); + + // SQL already references analytics.events + let (_, cands) = + completer.complete_at("SELECT FROM analytics.events", 7, "SELECT FROM analytics.events"); + let values: Vec<&str> = cands.iter().map(|c| c.value.as_str()).collect(); + assert!( + values.iter().any(|v| v.contains("analytics.events")), + "should show analytics.events when analytics is referenced" + ); + } + + /// Non-public table must show when the user explicitly types the schema prefix. + #[test] + fn test_tab_shows_nonpublic_table_when_typing_schema_prefix() { + use schema_cache::TableMetadata; + + let cache = Arc::new(SchemaCache::new(300)); + cache.inject_test_tables(vec![TableMetadata { + schema_name: "analytics".to_string(), + table_name: "events".to_string(), + columns: vec![], + }]); + let usage_tracker = Arc::new(UsageTracker::new(10)); + let completer = SqlCompleter::new(cache, usage_tracker, true); + + // User typed "analytics" (the schema name) in the no-schema-in-sql case + let (_, cands) = completer.complete_at("SELECT analytics FROM", 16, "SELECT analytics FROM"); + let values: Vec<&str> = cands.iter().map(|c| c.value.as_str()).collect(); + assert!( + values.iter().any(|v| v.contains("analytics")), + "should show analytics.events when user types analytics prefix" + ); + } + + // ── Dot-mode ────────────────────────────────────────────────────────────── + + /// "table." should produce columns from that table (when table is in SQL). + #[test] + fn test_dot_mode_shows_columns_when_table_in_sql() { + use schema_cache::{ColumnMetadata, TableMetadata}; + + let cache = Arc::new(SchemaCache::new(300)); + cache.inject_test_tables(vec![TableMetadata { + schema_name: "public".to_string(), + table_name: "test".to_string(), + columns: vec![ + ColumnMetadata { + name: "val".to_string(), + data_type: "integer".to_string(), + }, + ColumnMetadata { + name: "name".to_string(), + data_type: "text".to_string(), + }, + ], + }]); + let usage_tracker = Arc::new(UsageTracker::new(10)); + let completer = SqlCompleter::new(cache, usage_tracker, true); + + let (_, cands) = completer.complete_at("SELECT test. FROM test", 12, "SELECT test. FROM test"); + let values: Vec<&str> = cands.iter().map(|c| c.value.as_str()).collect(); + assert!(values.iter().any(|v| v.contains("val"))); + assert!(values.iter().any(|v| v.contains("name"))); + } + + /// Column filter should also work when the table is schema-qualified in SQL. + #[test] + fn test_tab_columns_schema_qualified_in_sql() { + use schema_cache::{ColumnMetadata, TableMetadata}; + + let cache = Arc::new(SchemaCache::new(300)); + cache.inject_test_tables(vec![TableMetadata { + schema_name: "information_schema".to_string(), + table_name: "engine_query_history".to_string(), + columns: vec![ColumnMetadata { + name: "start_time".to_string(), + data_type: "timestamptz".to_string(), + }], + }]); + let usage_tracker = Arc::new(UsageTracker::new(10)); + let completer = SqlCompleter::new(cache, usage_tracker, true); + + // Table referenced as "information_schema.engine_query_history" + let (_, cands) = completer + .complete_at("SELECT FROM information_schema.engine_query_history", 7, "SELECT FROM information_schema.engine_query_history"); + let values: Vec<&str> = cands.iter().map(|c| c.value.as_str()).collect(); + assert!( + values.iter().any(|v| v.contains("start_time")), + "should suggest start_time for information_schema.engine_query_history" + ); + } +} diff --git a/src/completion/priority_scorer.rs b/src/completion/priority_scorer.rs new file mode 100644 index 0000000..6a2fefe --- /dev/null +++ b/src/completion/priority_scorer.rs @@ -0,0 +1,277 @@ +use super::context_analyzer::ContextAnalyzer; +use super::usage_tracker::{ItemType, UsageTracker}; +use std::sync::Arc; + +/// Calculates priority scores for auto-completion suggestions +pub struct PriorityScorer { + usage_tracker: Arc, +} + +/// A suggestion with its calculated priority score +#[derive(Debug, Clone)] +pub struct ScoredSuggestion { + pub name: String, + pub item_type: ItemType, + pub score: u32, +} + +/// Priority classes determine the base score before usage bonuses +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PriorityClass { + /// Columns from tables mentioned in current query - highest priority + ColumnFromQueryTable = 5000, + + /// Tables - high priority when no table context + Table = 4000, + + /// Qualified columns from tables NOT in query + QualifiedColumnOtherTable = 3000, + + /// Unqualified columns from tables NOT in query + UnqualifiedColumnOtherTable = 2000, + + /// Functions - lower priority than columns + Function = 1000, + + /// System schema items - lowest priority + SystemSchema = 0, +} + +impl PriorityScorer { + pub fn new(usage_tracker: Arc) -> Self { + Self { usage_tracker } + } + + /// Calculate priority score for a suggestion + /// + /// # Arguments + /// * `name` - The suggestion name (e.g., "users", "user_id", "users.user_id") + /// * `item_type` - Whether this is a table or column + /// * `tables_in_statement` - Tables mentioned in the current SQL statement + /// * `column_table` - For columns, the table they belong to (if known) + /// + /// # Returns + /// Priority score (higher = more relevant) + pub fn score( + &self, + name: &str, + item_type: ItemType, + tables_in_statement: &[String], + column_table: Option<&str>, + ) -> u32 { + // For columns the tracker stores bare names; strip any "table." prefix. + let lookup_name: &str = if matches!(item_type, ItemType::Column) { + name.rsplit('.').next().unwrap_or(name) + } else { + name + }; + let usage_count = self.usage_tracker.get_count(item_type, lookup_name); + + // Calculate base score from priority class + let base_score = self.calculate_priority_class( + name, + item_type, + tables_in_statement, + column_table, + ) as u32; + + // Add usage bonus (max 99 uses = 990 points, ensures higher class always wins) + let usage_bonus = usage_count.min(99) * 10; + + base_score + usage_bonus + } + + /// Determine the priority class for an item + fn calculate_priority_class( + &self, + name: &str, + item_type: ItemType, + tables_in_statement: &[String], + column_table: Option<&str>, + ) -> PriorityClass { + // System schemas always get lowest priority + if ContextAnalyzer::is_system_schema(name) { + return PriorityClass::SystemSchema; + } + + match item_type { + ItemType::Table | ItemType::Schema => PriorityClass::Table, + + ItemType::Column => { + // Check if column belongs to a table in the statement + if let Some(table) = column_table { + // Normalize table name for comparison + let normalized_table = table.to_lowercase(); + + // Check if this table is mentioned in the statement + let table_in_statement = tables_in_statement.iter().any(|t| { + let normalized_t = t.to_lowercase(); + normalized_t == normalized_table + || normalized_t.ends_with(&format!(".{}", normalized_table)) + || normalized_table.ends_with(&format!(".{}", normalized_t)) + }); + + if table_in_statement { + // Column from table in query - highest priority + return PriorityClass::ColumnFromQueryTable; + } + } + + // Column from other table - check if qualified + let is_qualified = name.contains('.'); + + if is_qualified { + PriorityClass::QualifiedColumnOtherTable + } else { + PriorityClass::UnqualifiedColumnOtherTable + } + } + + ItemType::Function => PriorityClass::Function, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_scorer() -> PriorityScorer { + let tracker = Arc::new(UsageTracker::new(10)); + PriorityScorer::new(tracker) + } + + #[test] + fn test_tables_prioritized_when_no_context() { + let scorer = create_test_scorer(); + + let table_score = scorer.score("users", ItemType::Table, &[], None); + let column_score = scorer.score("user_id", ItemType::Column, &[], None); + + assert!(table_score > column_score, "Tables should be prioritized over columns when no context"); + assert!(table_score >= 4000, "Table should be in Table priority class"); + assert!(column_score < 3000, "Column should be in lower priority class"); + } + + #[test] + fn test_columns_prioritized_when_table_in_statement() { + let scorer = create_test_scorer(); + + let column_score = scorer.score( + "user_id", + ItemType::Column, + &["users".to_string()], + Some("users"), + ); + + assert!(column_score >= 5000, "Column from query table should be highest priority"); + } + + #[test] + fn test_system_schema_deprioritized() { + let scorer = create_test_scorer(); + + let user_table_score = scorer.score("users", ItemType::Table, &[], None); + let system_table_score = scorer.score("information_schema.tables", ItemType::Table, &[], None); + + assert!(user_table_score > system_table_score, "User tables should be prioritized over system schemas"); + assert!(system_table_score < 1000, "System schema should be in lowest priority class"); + } + + #[test] + fn test_qualified_vs_unqualified_columns() { + let scorer = create_test_scorer(); + + let qualified_score = scorer.score( + "orders.order_id", + ItemType::Column, + &["users".to_string()], // Different table in statement + Some("orders"), + ); + + let unqualified_score = scorer.score( + "order_id", + ItemType::Column, + &["users".to_string()], + Some("orders"), + ); + + assert!(qualified_score > unqualified_score, "Qualified columns should rank above unqualified"); + assert!(qualified_score >= 3000 && qualified_score < 4000, "Qualified column should be in correct class"); + assert!(unqualified_score >= 2000 && unqualified_score < 3000, "Unqualified column should be in correct class"); + } + + #[test] + fn test_usage_bonus_within_class() { + let tracker = Arc::new(UsageTracker::new(10)); + + // Simulate usage + for _ in 0..50 { + tracker.track_query("SELECT * FROM users"); + } + for _ in 0..10 { + tracker.track_query("SELECT * FROM orders"); + } + + let scorer = PriorityScorer::new(tracker); + + let users_score = scorer.score("users", ItemType::Table, &[], None); + let orders_score = scorer.score("orders", ItemType::Table, &[], None); + + assert!(users_score > orders_score, "More frequently used item should score higher"); + + // Both should be in same priority class (4000-4999) + assert!(users_score >= 4000 && users_score < 5000); + assert!(orders_score >= 4000 && orders_score < 5000); + } + + #[test] + fn test_priority_class_beats_usage_count() { + let tracker = Arc::new(UsageTracker::new(10)); + + // Give column very high usage + for _ in 0..99 { + tracker.track_query("SELECT order_id FROM orders"); + } + + let scorer = PriorityScorer::new(tracker.clone()); + + // Column with high usage from other table + let column_score = scorer.score( + "order_id", + ItemType::Column, + &["users".to_string()], // Different table + Some("orders"), + ); + + // Table with no usage + let table_score = scorer.score("users", ItemType::Table, &[], None); + + // Table should still win because higher priority class + assert!(table_score > column_score, "Higher priority class should beat usage count"); + } + + #[test] + fn test_schema_qualified_table_matching() { + let scorer = create_test_scorer(); + + // Test that schema-qualified names are handled correctly + let score1 = scorer.score( + "user_id", + ItemType::Column, + &["public.users".to_string()], + Some("users"), + ); + + let score2 = scorer.score( + "user_id", + ItemType::Column, + &["users".to_string()], + Some("public.users"), + ); + + // Both should recognize the table match + assert!(score1 >= 5000, "Should match schema.table to table"); + assert!(score2 >= 5000, "Should match table to schema.table"); + } +} diff --git a/src/completion/schema_cache.rs b/src/completion/schema_cache.rs new file mode 100644 index 0000000..2d651f1 --- /dev/null +++ b/src/completion/schema_cache.rs @@ -0,0 +1,839 @@ +use crate::context::Context; +use crate::query::query_silent; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; + +#[derive(Clone)] +pub struct TableMetadata { + pub schema_name: String, + pub table_name: String, + pub columns: Vec, +} + +#[derive(Clone)] +#[allow(dead_code)] +pub struct ColumnMetadata { + pub name: String, + pub data_type: String, +} + +#[allow(dead_code)] +pub struct SchemaCache { + tables: Arc>>, + functions: Arc>>, + /// Maps lowercase function name → list of signature strings, e.g. + /// `"upper"` → `["upper(text)"]`. Empty until a successful parameters query. + function_signatures: Arc>>>, + keywords: HashSet, + last_refresh: Arc>>, + ttl: Duration, + refreshing: Arc>, +} + +impl SchemaCache { + pub fn new(ttl_seconds: u64) -> Self { + Self { + tables: Arc::new(RwLock::new(HashMap::new())), + functions: Arc::new(RwLock::new(HashSet::new())), + function_signatures: Arc::new(RwLock::new(HashMap::new())), + keywords: Self::load_keywords(), + last_refresh: Arc::new(RwLock::new(None)), + ttl: Duration::from_secs(ttl_seconds), + refreshing: Arc::new(RwLock::new(false)), + } + } + + fn load_keywords() -> HashSet { + let keywords = vec![ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER", + "ON", "GROUP", "ORDER", "BY", "HAVING", "LIMIT", "OFFSET", "UNION", "INTERSECT", + "EXCEPT", "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", "CREATE", "DROP", + "ALTER", "TABLE", "DATABASE", "INDEX", "VIEW", "AS", "DISTINCT", "ALL", "AND", + "OR", "NOT", "IN", "EXISTS", "BETWEEN", "LIKE", "IS", "NULL", "CASE", "WHEN", + "THEN", "ELSE", "END", "WITH", "RECURSIVE", "ASC", "DESC", "COUNT", "SUM", "AVG", + "MIN", "MAX", "CAST", "SUBSTRING", "TRIM", "UPPER", "LOWER", "COALESCE", "NULLIF", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CHECK", "DEFAULT", "AUTO_INCREMENT", + "EXPLAIN", "DESCRIBE", "SHOW", "USE", "GRANT", "REVOKE", "COMMIT", "ROLLBACK", + "SAVEPOINT", "TRANSACTION", "BEGIN", "START", "TRUNCATE", "RENAME", "ADD", "MODIFY", + "COLUMN", "CONSTRAINT", "CASCADE", "RESTRICT", + ]; + + keywords.into_iter().map(String::from).collect() + } + + /// Checks if cache is stale based on TTL + #[allow(dead_code)] + pub fn is_stale(&self) -> bool { + let last_refresh = self.last_refresh.read().unwrap(); + match *last_refresh { + Some(instant) => instant.elapsed() > self.ttl, + None => true, // Never refreshed + } + } + + /// Returns true if a refresh is currently in progress + pub fn is_refreshing(&self) -> bool { + *self.refreshing.read().unwrap() + } + + /// Marks refresh as started + fn start_refresh(&self) { + *self.refreshing.write().unwrap() = true; + } + + /// Marks refresh as complete and updates timestamp + fn complete_refresh(&self) { + *self.refreshing.write().unwrap() = false; + *self.last_refresh.write().unwrap() = Some(Instant::now()); + } + + /// Get all keywords matching prefix + #[allow(dead_code)] + pub fn get_keywords(&self, prefix: &str) -> Vec { + let prefix_lower = prefix.to_lowercase(); + self.keywords + .iter() + .filter(|k| k.to_lowercase().starts_with(&prefix_lower)) + .cloned() + .collect() + } + + /// Get all table names matching prefix + #[allow(dead_code)] + pub fn get_tables(&self, prefix: &str) -> Vec { + let prefix_lower = prefix.to_lowercase(); + let tables = self.tables.read().unwrap(); + tables + .values() + .map(|t| { + if t.schema_name == "public" || t.schema_name.is_empty() { + t.table_name.clone() + } else { + format!("{}.{}", t.schema_name, t.table_name) + } + }) + .filter(|name| name.to_lowercase().starts_with(&prefix_lower)) + .collect() + } + + /// Get all column names matching prefix + #[allow(dead_code)] + pub fn get_columns(&self, prefix: &str) -> Vec { + let prefix_lower = prefix.to_lowercase(); + let tables = self.tables.read().unwrap(); + let mut columns = HashSet::new(); + for table in tables.values() { + for column in &table.columns { + columns.insert(column.name.clone()); + } + } + columns + .into_iter() + .filter(|name| name.to_lowercase().starts_with(&prefix_lower)) + .collect() + } + + /// Get all unique schema names matching prefix + #[allow(dead_code)] + pub fn get_schemas(&self, prefix: &str) -> Vec { + let prefix_lower = prefix.to_lowercase(); + let tables = self.tables.read().unwrap(); + + let mut schemas = std::collections::HashSet::new(); + for table in tables.values() { + if !table.schema_name.is_empty() { + schemas.insert(table.schema_name.clone()); + } + } + + schemas + .into_iter() + .filter(|schema| schema.to_lowercase().starts_with(&prefix_lower)) + .collect() + } + + /// Get all function names matching prefix + #[allow(dead_code)] + pub fn get_functions(&self, prefix: &str) -> Vec { + let prefix_lower = prefix.to_lowercase(); + let functions = self.functions.read().unwrap(); + + functions + .iter() + .filter(|f| f.to_lowercase().starts_with(&prefix_lower)) + .cloned() + .collect() + } + + /// Get all tables from a specific schema, optionally filtered by table name prefix + pub fn get_tables_in_schema(&self, schema: &str, table_prefix: &str) -> Vec { + let schema_lower = schema.to_lowercase(); + let prefix_lower = table_prefix.to_lowercase(); + let tables = self.tables.read().unwrap(); + + let mut result: Vec = tables + .values() + .filter(|t| t.schema_name.to_lowercase() == schema_lower) + .filter(|t| { + if prefix_lower.is_empty() { + true + } else { + t.table_name.to_lowercase().starts_with(&prefix_lower) + } + }) + .map(|t| t.table_name.clone()) + .collect(); + + result.sort(); + result + } + + /// Get all table names with their schemas matching prefix + /// Returns Vec<(schema, table)> + #[allow(dead_code)] + pub fn get_tables_with_schema(&self, prefix: &str) -> Vec<(String, String)> { + let prefix_lower = prefix.to_lowercase(); + let tables = self.tables.read().unwrap(); + + let mut result: Vec<(String, String)> = tables + .values() + .filter_map(|t| { + let short_name = t.table_name.to_lowercase(); + let qualified_name = format!("{}.{}", t.schema_name, t.table_name).to_lowercase(); + + // Match either short name or qualified name + if short_name.starts_with(&prefix_lower) || qualified_name.starts_with(&prefix_lower) { + Some((t.schema_name.clone(), t.table_name.clone())) + } else { + None + } + }) + .collect(); + + // Sort by table name for consistent ordering + result.sort_by(|a, b| a.1.cmp(&b.1)); + result + } + + /// Get all column names with their table names matching prefix + /// Returns Vec<(Option, column)> + pub fn get_columns_with_table(&self, prefix: &str) -> Vec<(Option, String)> { + let prefix_lower = prefix.to_lowercase(); + let tables = self.tables.read().unwrap(); + + let mut result: Vec<(Option, String)> = Vec::new(); + let mut seen_columns = HashSet::new(); + + for table in tables.values() { + for column in &table.columns { + let column_name = column.name.to_lowercase(); + let qualified_name = format!("{}.{}", table.table_name, column.name).to_lowercase(); + + // Check if matches prefix (either column name or qualified name) + if column_name.starts_with(&prefix_lower) || qualified_name.starts_with(&prefix_lower) { + // Track unique columns to avoid duplicates + let key = format!("{}:{}", table.table_name, column.name); + if !seen_columns.contains(&key) { + result.push((Some(table.table_name.clone()), column.name.clone())); + seen_columns.insert(key); + } + } + } + } + + // Sort by column name for consistent ordering + result.sort_by(|a, b| a.1.cmp(&b.1)); + result + } + + /// Get the table for a given column name + /// Returns the first table that contains this column + #[allow(dead_code)] + pub fn get_table_for_column(&self, column_name: &str) -> Option { + let tables = self.tables.read().unwrap(); + let column_lower = column_name.to_lowercase(); + + for table in tables.values() { + for column in &table.columns { + if column.name.to_lowercase() == column_lower { + // Return qualified table name + return Some(if table.schema_name == "public" || table.schema_name.is_empty() { + table.table_name.clone() + } else { + format!("{}.{}", table.schema_name, table.table_name) + }); + } + } + } + + None + } + + /// Return ALL (schema, table) pairs — used by the fuzzy completer. + pub fn get_all_tables(&self) -> Vec<(String, String)> { + let tables = self.tables.read().unwrap(); + let mut result: Vec<(String, String)> = tables + .values() + .map(|t| (t.schema_name.clone(), t.table_name.clone())) + .collect(); + result.sort_by(|a, b| a.1.cmp(&b.1)); + result + } + + /// Return ALL (schema, table, column) triples — used by the fuzzy completer. + pub fn get_all_columns(&self) -> Vec<(String, String, String)> { + let tables = self.tables.read().unwrap(); + let mut result: Vec<(String, String, String)> = tables + .values() + .flat_map(|t| { + let schema = t.schema_name.clone(); + let table = t.table_name.clone(); + t.columns + .iter() + .map(move |c| (schema.clone(), table.clone(), c.name.clone())) + }) + .collect(); + result.sort_by(|a, b| a.2.cmp(&b.2)); + result + } + + /// Return ALL function names — used by the fuzzy completer. + pub fn get_all_functions(&self) -> Vec { + let fns = self.functions.read().unwrap(); + let mut result: Vec = fns.iter().cloned().collect(); + result.sort(); + result + } + + /// Return the known signatures for a function (lowercase name). + /// Returns an empty Vec if no signature data was loaded from the server. + pub fn get_signatures(&self, func_name: &str) -> Vec { + let sigs = self.function_signatures.read().unwrap(); + sigs.get(&func_name.to_lowercase()) + .cloned() + .unwrap_or_default() + } + + /// Async method to refresh schema from database + pub async fn refresh(&self, context: &mut Context) -> Result<(), Box> { + // Check if already refreshing + if self.is_refreshing() { + return Ok(()); + } + + self.start_refresh(); + + let result = self.do_refresh(context).await; + + if result.is_ok() { + self.complete_refresh(); + } else { + // Still mark as complete to avoid blocking future attempts + *self.refreshing.write().unwrap() = false; + } + + result + } + + /// Test-only helper: directly populate the cache with the given tables. + #[cfg(test)] + pub fn inject_test_tables(&self, tables: Vec) { + let mut map = std::collections::HashMap::new(); + for t in tables { + let key = format!("{}.{}", t.schema_name, t.table_name); + map.insert(key, t); + } + *self.tables.write().unwrap() = map; + self.complete_refresh(); + } + + async fn do_refresh(&self, context: &mut Context) -> Result<(), Box> { + // ── Step 1: tables (health-check query) ───────────────────────────── + // Run this first and bail early on any failure so we don't waste time + // running the other three queries against an unavailable server. + let tables_query = "SELECT table_schema, table_name \ + FROM information_schema.tables \ + ORDER BY table_schema, table_name"; + + let tables_output = match query_silent(context, tables_query).await { + Err(e) => { + // Network/connection failure — report it. + context.emit_err(format!("Warning: Tables query failed: {}", e)); + return Err(e); + } + Ok(body) => { + // The server might respond HTTP 200 but with an error body when the + // cluster is still starting up ("Cluster not yet healthy"). Treat + // that the same as a connection failure so the retry loop keeps waiting. + let is_error_body = body.lines().any(|line| { + serde_json::from_str::(line) + .ok() + .map(|j| j.get("errors").is_some()) + .unwrap_or(false) + }); + if is_error_body { + return Err("Server not ready".into()); + } + body + } + }; + + // ── Step 2: remaining queries (only reached when server is healthy) ── + let columns_query = "SELECT table_schema, table_name, column_name, data_type \ + FROM information_schema.columns \ + ORDER BY table_schema, table_name, ordinal_position"; + let columns_result = query_silent(context, columns_query).await; + + let functions_query = "SELECT routine_name \ + FROM information_schema.routines \ + WHERE routine_type <> 'OPERATOR' \ + ORDER BY routine_name"; + let functions_result = query_silent(context, functions_query).await; + + // Try new format first: includes is_variadic; routine_parameters is array of structs. + // Falls back to old format (array of type strings) if new columns aren't available. + let new_sigs_query = "SELECT routine_name, routine_parameters, is_variadic \ + FROM information_schema.routines \ + WHERE routine_type <> 'OPERATOR' \ + ORDER BY routine_name"; + let sigs_raw = query_silent(context, new_sigs_query).await; + let signatures_result: Option = match sigs_raw { + Ok(body) => { + let has_error = body.lines().any(|line| { + serde_json::from_str::(line) + .ok() + .map(|j| j.get("errors").is_some()) + .unwrap_or(false) + }); + if has_error { + // Old server: no is_variadic column; fall back + let old_sigs_query = "SELECT routine_name, routine_parameters \ + FROM information_schema.routines \ + WHERE routine_type <> 'OPERATOR' \ + ORDER BY routine_name"; + query_silent(context, old_sigs_query).await.ok() + } else { + Some(body) + } + } + Err(_) => None, + }; + + // ── Step 3: parse and populate cache ──────────────────────────────── + let mut new_tables = HashMap::new(); + + if let Some(table_list) = Self::parse_tables(&tables_output) { + for (schema, table) in table_list { + let key = format!("{}.{}", schema, table); + new_tables.insert( + key, + TableMetadata { + schema_name: schema, + table_name: table, + columns: Vec::new(), + }, + ); + } + } else { + context.emit_err(format!( + "Warning: Failed to parse tables from schema query. Output: {}", + &tables_output[..tables_output.len().min(200)] + )); + } + + // Parse columns and add to tables. + // Use the entry API so that tables appearing only in information_schema.columns + // (but not in information_schema.tables) still get cache entries with their columns. + match columns_result { + Ok(columns_output) => { + if let Some(column_list) = Self::parse_columns(&columns_output) { + for (schema, table, column, data_type) in column_list { + let key = format!("{}.{}", schema, table); + let table_meta = new_tables.entry(key).or_insert_with(|| TableMetadata { + schema_name: schema.clone(), + table_name: table.clone(), + columns: Vec::new(), + }); + table_meta.columns.push(ColumnMetadata { + name: column, + data_type, + }); + } + } else { + context.emit_err("Warning: Failed to parse columns from schema query".to_string()); + } + } + Err(e) => { + context.emit_err(format!("Warning: Columns query failed: {}", e)); + } + } + + // Update tables cache + *self.tables.write().unwrap() = new_tables; + + // Parse functions + match functions_result { + Ok(functions_output) => { + if let Some(function_list) = Self::parse_functions(&functions_output) { + *self.functions.write().unwrap() = function_list.into_iter().collect(); + } else { + context.emit_err("Warning: Failed to parse functions from schema query".to_string()); + } + } + Err(e) => { + context.emit_err(format!("Warning: Functions query failed: {}", e)); + } + } + + // Parse function signatures (best-effort; silently ignored on failure) + if let Some(sig_output) = signatures_result { + if let Some(sig_map) = Self::parse_function_signatures(&sig_output) { + *self.function_signatures.write().unwrap() = sig_map; + } + } + + Ok(()) + } + + fn parse_tables(output: &str) -> Option> { + let mut result = Vec::new(); + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + // Parse message-based format + if let Ok(json) = serde_json::from_str::(line) { + // Check for DATA message type + if let Some(msg_type) = json.get("message_type").and_then(|v| v.as_str()) { + if msg_type == "DATA" { + // Extract data array: [["public","test"],["public","test_view"]] + if let Some(data) = json.get("data").and_then(|v| v.as_array()) { + for row in data { + if let Some(row_array) = row.as_array() { + if row_array.len() >= 2 { + let schema = row_array[0].as_str().unwrap_or("").to_string(); + let table = row_array[1].as_str().unwrap_or("").to_string(); + if !schema.is_empty() && !table.is_empty() { + result.push((schema, table)); + } + } + } + } + } + } + } else { + // Try old JSONLines_Compact format for backwards compatibility + if let (Some(schema), Some(table)) = ( + json.get("table_schema").and_then(|v| v.as_str()), + json.get("table_name").and_then(|v| v.as_str()), + ) { + result.push((schema.to_string(), table.to_string())); + } + } + } + } + + if result.is_empty() { + None + } else { + Some(result) + } + } + + fn parse_columns(output: &str) -> Option> { + let mut result = Vec::new(); + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + // Parse message-based format + if let Ok(json) = serde_json::from_str::(line) { + // Check for DATA message type + if let Some(msg_type) = json.get("message_type").and_then(|v| v.as_str()) { + if msg_type == "DATA" { + // Extract data array: [["public","test","id","integer"],...] + if let Some(data) = json.get("data").and_then(|v| v.as_array()) { + for row in data { + if let Some(row_array) = row.as_array() { + if row_array.len() >= 4 { + let schema = row_array[0].as_str().unwrap_or("").to_string(); + let table = row_array[1].as_str().unwrap_or("").to_string(); + let column = row_array[2].as_str().unwrap_or("").to_string(); + let data_type = row_array[3].as_str().unwrap_or("").to_string(); + if !schema.is_empty() && !table.is_empty() && !column.is_empty() { + result.push((schema, table, column, data_type)); + } + } + } + } + } + } + } else { + // Try old JSONLines_Compact format for backwards compatibility + if let (Some(schema), Some(table), Some(column), Some(data_type)) = ( + json.get("table_schema").and_then(|v| v.as_str()), + json.get("table_name").and_then(|v| v.as_str()), + json.get("column_name").and_then(|v| v.as_str()), + json.get("data_type").and_then(|v| v.as_str()), + ) { + result.push(( + schema.to_string(), + table.to_string(), + column.to_string(), + data_type.to_string(), + )); + } + } + } + } + + if result.is_empty() { + None + } else { + Some(result) + } + } + + fn parse_functions(output: &str) -> Option> { + let mut result = Vec::new(); + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + // Parse message-based format + if let Ok(json) = serde_json::from_str::(line) { + // Check for DATA message type + if let Some(msg_type) = json.get("message_type").and_then(|v| v.as_str()) { + if msg_type == "DATA" { + // Extract data array: [["function1"],["function2"],...] + if let Some(data) = json.get("data").and_then(|v| v.as_array()) { + for row in data { + if let Some(row_array) = row.as_array() { + if !row_array.is_empty() { + if let Some(routine_name) = row_array[0].as_str() { + if !routine_name.is_empty() { + result.push(routine_name.to_string()); + } + } + } + } + } + } + } + } else { + // Try old JSONLines_Compact format for backwards compatibility + if let Some(routine_name) = json.get("routine_name").and_then(|v| v.as_str()) { + result.push(routine_name.to_string()); + } + } + } + } + + // Always return Some - empty result is valid (no functions defined) + // Only return None if output is completely empty/unparseable + if output.trim().is_empty() { + None + } else { + Some(result) + } + } + + /// Parse `(routine_name, routine_parameters[, is_variadic])` rows from + /// `information_schema.routines` into a map of lowercase function name → signature strings. + /// + /// Supports two formats for `routine_parameters`: + /// - **New**: array of structs `[{type, name, default_value, comment}, ...]` (+ `is_variadic`) + /// - **Old**: array of type strings `["text", "integer", ...]` + fn parse_function_signatures(output: &str) -> Option>> { + let mut sig_map: HashMap> = HashMap::new(); + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { continue; } + + if let Ok(json) = serde_json::from_str::(line) { + if let Some(msg_type) = json.get("message_type").and_then(|v| v.as_str()) { + if msg_type == "DATA" { + if let Some(data) = json.get("data").and_then(|v| v.as_array()) { + for row in data { + if let Some(arr) = row.as_array() { + if arr.len() < 2 { continue; } + let rname = arr[0].as_str().unwrap_or("").trim().to_string(); + if rname.is_empty() { continue; } + + // arr[2] = is_variadic (new format only; absent in old) + let is_variadic = arr.get(2) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let params: Vec = if let Some(param_arr) = arr[1].as_array() { + if param_arr.is_empty() { + vec![] + } else if param_arr[0].is_object() { + // New format: [{type, name, default_value, comment}] + param_arr.iter().enumerate().map(|(i, p)| { + let typ = p.get("type").and_then(|v| v.as_str()).unwrap_or("?").to_uppercase(); + let name = p.get("name").and_then(|v| v.as_str()); + let default = p.get("default_value").and_then(|v| v.as_str()); + let is_last = i == param_arr.len() - 1; + + // Build: [name ]TYPE[...] [=> default] + let mut s = if let Some(n) = name { + if is_variadic && is_last { + format!("{} {}...", n, typ) + } else { + format!("{} {}", n, typ) + } + } else if is_variadic && is_last { + format!("{}...", typ) + } else { + typ + }; + + if let Some(d) = default { + if !d.is_empty() { + s = format!("{} => {}", s, d); + } + } + + s + }).collect() + } else { + // Old format: ["text", "integer", ...] + param_arr.iter().enumerate().map(|(i, v)| { + let typ = v.as_str().unwrap_or("?").to_uppercase(); + if is_variadic && i == param_arr.len() - 1 { + format!("{}...", typ) + } else { + typ + } + }).collect() + } + } else { + vec![] + }; + + let sig = format!("{}({})", rname, params.join(", ")); + let entry = sig_map.entry(rname.to_lowercase()).or_default(); + if !entry.contains(&sig) { + entry.push(sig); + } + } + } + } + } + } + } + } + + if sig_map.is_empty() { None } else { Some(sig_map) } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_tables() { + // Test new message-based format + let output = r#"{"message_type":"START","result_columns":[{"name":"table_schema","type":"text"},{"name":"table_name","type":"text"}]} +{"message_type":"DATA","data":[["public","users"],["public","orders"]]} +{"message_type":"FINISH_SUCCESSFULLY","statistics":{}}"#; + let tables = SchemaCache::parse_tables(output).unwrap(); + assert_eq!(tables.len(), 2); + assert_eq!(tables[0], ("public".to_string(), "users".to_string())); + assert_eq!(tables[1], ("public".to_string(), "orders".to_string())); + + // Test old JSONLines_Compact format for backwards compatibility + let output_old = r#"{"table_schema":"public","table_name":"legacy_table"}"#; + let tables_old = SchemaCache::parse_tables(output_old).unwrap(); + assert_eq!(tables_old.len(), 1); + assert_eq!(tables_old[0], ("public".to_string(), "legacy_table".to_string())); + } + + #[test] + fn test_parse_columns() { + // Test new message-based format + let output = r#"{"message_type":"START","result_columns":[]} +{"message_type":"DATA","data":[["public","users","id","INTEGER"],["public","users","name","TEXT"]]} +{"message_type":"FINISH_SUCCESSFULLY","statistics":{}}"#; + let columns = SchemaCache::parse_columns(output).unwrap(); + assert_eq!(columns.len(), 2); + assert_eq!( + columns[0], + ( + "public".to_string(), + "users".to_string(), + "id".to_string(), + "INTEGER".to_string() + ) + ); + } + + #[test] + fn test_parse_function_signatures_new_format() { + // New format: routine_parameters is array of structs, with is_variadic + let output = r#"{"message_type":"DATA","data":[["date_add",[{"type":"text","name":"unit","default_value":null,"comment":""},{"type":"integer","name":"value","default_value":null,"comment":""},{"type":"timestamp","name":"date","default_value":null,"comment":""}],false],["coalesce",[{"type":"text","name":null,"default_value":null,"comment":""}],true],["upper",[{"type":"text","name":null,"default_value":null,"comment":""}],false]]}"#; + let sigs = SchemaCache::parse_function_signatures(output).unwrap(); + assert_eq!(sigs["date_add"], vec!["date_add(unit TEXT, value INTEGER, date TIMESTAMP)"]); + assert_eq!(sigs["coalesce"], vec!["coalesce(TEXT...)"]); + assert_eq!(sigs["upper"], vec!["upper(TEXT)"]); + } + + #[test] + fn test_parse_function_signatures_old_format() { + // Old format: routine_parameters is array of type strings, no is_variadic + let output = r#"{"message_type":"DATA","data":[["upper",["text"]],["date_add",["text","integer","timestamp"]]]}"#; + let sigs = SchemaCache::parse_function_signatures(output).unwrap(); + assert_eq!(sigs["upper"], vec!["upper(TEXT)"]); + assert_eq!(sigs["date_add"], vec!["date_add(TEXT, INTEGER, TIMESTAMP)"]); + } + + #[test] + fn test_keywords_loaded() { + let cache = SchemaCache::new(300); + assert!(cache.keywords.contains("SELECT")); + assert!(cache.keywords.contains("FROM")); + assert!(cache.keywords.contains("WHERE")); + } + + #[test] + fn test_is_stale() { + let cache = SchemaCache::new(1); // 1 second TTL + assert!(cache.is_stale()); // Never refreshed + + cache.complete_refresh(); + assert!(!cache.is_stale()); + + std::thread::sleep(Duration::from_millis(1100)); + assert!(cache.is_stale()); + } + + #[test] + fn test_get_keywords() { + let cache = SchemaCache::new(300); + let keywords = cache.get_keywords("SEL"); + assert!(keywords.contains(&"SELECT".to_string())); + } + + #[test] + fn test_empty_cache() { + let cache = SchemaCache::new(300); + // Cache should have keywords even when empty + assert!(cache.get_keywords("").len() > 0); + // But no tables or columns yet + assert_eq!(cache.get_tables("").len(), 0); + assert_eq!(cache.get_columns("").len(), 0); + } +} diff --git a/src/completion/usage_tracker.rs b/src/completion/usage_tracker.rs new file mode 100644 index 0000000..b915902 --- /dev/null +++ b/src/completion/usage_tracker.rs @@ -0,0 +1,317 @@ +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ItemType { + Table, + Column, + Function, + Schema, +} + +/// Tracks usage frequency of tables, columns, and functions to enable intelligent prioritization +pub struct UsageTracker { + // Item name -> usage count + table_counts: Arc>>, + column_counts: Arc>>, + function_counts: Arc>>, + + // Recent queries (ring buffer of last N) + recent_queries: Arc>>, + max_recent: usize, +} + +impl UsageTracker { + /// Create a new UsageTracker with a maximum number of recent queries to track + pub fn new(max_recent: usize) -> Self { + Self { + table_counts: Arc::new(RwLock::new(HashMap::new())), + column_counts: Arc::new(RwLock::new(HashMap::new())), + function_counts: Arc::new(RwLock::new(HashMap::new())), + recent_queries: Arc::new(RwLock::new(Vec::new())), + max_recent, + } + } + + /// Track a query execution by extracting table and column names + pub fn track_query(&self, query: &str) { + // 1. Add to recent queries (ring buffer) + { + let mut recent = self.recent_queries.write().unwrap(); + recent.push(query.to_string()); + if recent.len() > self.max_recent { + recent.remove(0); + } + } + + // 2. Extract table and column names + let tables = Self::extract_table_names(query); + let columns = Self::extract_column_names(query); + + // 3. Increment usage counts + { + let mut table_counts = self.table_counts.write().unwrap(); + for table in tables { + *table_counts.entry(table).or_insert(0) += 1; + } + } + + { + let mut column_counts = self.column_counts.write().unwrap(); + for column in columns { + *column_counts.entry(column).or_insert(0) += 1; + } + } + } + + /// Get usage count for an item + pub fn get_count(&self, item_type: ItemType, name: &str) -> u32 { + match item_type { + ItemType::Table => { + let counts = self.table_counts.read().unwrap(); + counts.get(name).copied().unwrap_or(0) + } + ItemType::Column => { + let counts = self.column_counts.read().unwrap(); + counts.get(name).copied().unwrap_or(0) + } + ItemType::Function => { + let counts = self.function_counts.read().unwrap(); + counts.get(name).copied().unwrap_or(0) + } + ItemType::Schema => 0, + } + } + + #[allow(dead_code)] + /// Check if an item name appears in any recent query + pub fn was_used_recently(&self, name: &str) -> bool { + let recent = self.recent_queries.read().unwrap(); + recent.iter().any(|q| q.contains(name)) + } + + /// Extract table names from SQL query using simple pattern matching + fn extract_table_names(query: &str) -> Vec { + let mut tables = Vec::new(); + let query_upper = query.to_uppercase(); + + // Pattern: FROM table_name + if let Some(from_pos) = query_upper.find("FROM") { + let after_from = &query[from_pos + 4..]; + if let Some(word) = Self::extract_first_word(after_from) { + tables.push(word); + } + } + + // Pattern: JOIN table_name + for (idx, _) in query_upper.match_indices("JOIN") { + let after_join = &query[idx + 4..]; + if let Some(word) = Self::extract_first_word(after_join) { + tables.push(word); + } + } + + // Pattern: UPDATE table_name + if let Some(update_pos) = query_upper.find("UPDATE") { + let after_update = &query[update_pos + 6..]; + if let Some(word) = Self::extract_first_word(after_update) { + tables.push(word); + } + } + + // Pattern: INTO table_name + if let Some(into_pos) = query_upper.find("INTO") { + let after_into = &query[into_pos + 4..]; + if let Some(word) = Self::extract_first_word(after_into) { + tables.push(word); + } + } + + tables + } + + /// Extract column names from SQL query using simple pattern matching + fn extract_column_names(query: &str) -> Vec { + let mut columns = Vec::new(); + let query_upper = query.to_uppercase(); + + // Pattern: SELECT columns (up to the next major clause or end of string) + // Works regardless of whether FROM appears before or after SELECT. + if let Some(select_pos) = query_upper.find("SELECT") { + let rest_upper = &query_upper[select_pos + 6..]; + let end = ["FROM", "WHERE", "GROUP", "ORDER", "HAVING", "LIMIT", "UNION"] + .iter() + .filter_map(|kw| rest_upper.find(kw)) + .min() + .unwrap_or(rest_upper.len()); + let between = &query[select_pos + 6..select_pos + 6 + end]; + columns.extend(Self::extract_column_list(between)); + } + + // Pattern: WHERE column = value + for (idx, _) in query_upper.match_indices("WHERE") { + let after_where = &query[idx + 5..]; + if let Some(word) = Self::extract_first_word(after_where) { + columns.push(word); + } + } + + // Pattern: ORDER BY column + for (idx, _) in query_upper.match_indices("ORDER BY") { + let after_order = &query[idx + 8..]; + if let Some(word) = Self::extract_first_word(after_order) { + columns.push(word); + } + } + + // Pattern: GROUP BY column + for (idx, _) in query_upper.match_indices("GROUP BY") { + let after_group = &query[idx + 8..]; + if let Some(word) = Self::extract_first_word(after_group) { + columns.push(word); + } + } + + columns + } + + /// Extract first identifier from text (handles schema.table notation) + fn extract_first_word(text: &str) -> Option { + let trimmed = text.trim(); + let mut word = String::new(); + + for ch in trimmed.chars() { + if ch.is_alphanumeric() || ch == '_' || ch == '.' { + word.push(ch); + } else if !word.is_empty() { + break; + } + } + + if word.is_empty() { + None + } else { + Some(word) + } + } + + /// Extract comma-separated column list + fn extract_column_list(text: &str) -> Vec { + let mut columns = Vec::new(); + + for part in text.split(',') { + let trimmed = part.trim(); + + // Skip * and common SQL functions/keywords + if trimmed == "*" || trimmed.is_empty() { + continue; + } + + // Extract just the column name (before AS, spaces, etc.) + if let Some(word) = Self::extract_first_word(trimmed) { + // Remove table prefix if present (e.g., "users.id" -> "id") + let column_name = if let Some(dot_pos) = word.rfind('.') { + word[dot_pos + 1..].to_string() + } else { + word + }; + + // Filter out SQL keywords + let upper = column_name.to_uppercase(); + if !["AS", "FROM", "WHERE", "AND", "OR", "NOT", "IN", "EXISTS"].contains(&upper.as_str()) { + columns.push(column_name); + } + } + } + + columns + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_track_query_increments_table_counts() { + let tracker = UsageTracker::new(10); + tracker.track_query("SELECT * FROM users"); + assert_eq!(tracker.get_count(ItemType::Table, "users"), 1); + + tracker.track_query("SELECT * FROM users"); + assert_eq!(tracker.get_count(ItemType::Table, "users"), 2); + } + + #[test] + fn test_track_query_increments_column_counts() { + let tracker = UsageTracker::new(10); + tracker.track_query("SELECT user_id, username FROM users"); + assert_eq!(tracker.get_count(ItemType::Column, "user_id"), 1); + assert_eq!(tracker.get_count(ItemType::Column, "username"), 1); + } + + #[test] + fn test_recent_queries_ring_buffer() { + let tracker = UsageTracker::new(2); + tracker.track_query("query1"); + tracker.track_query("query2"); + tracker.track_query("query3"); // Should evict query1 + + assert!(!tracker.was_used_recently("query1")); + assert!(tracker.was_used_recently("query2")); + assert!(tracker.was_used_recently("query3")); + } + + #[test] + fn test_extract_table_names_from_query() { + let tables = UsageTracker::extract_table_names( + "SELECT * FROM users JOIN orders ON users.id = orders.user_id" + ); + assert!(tables.contains(&"users".to_string())); + assert!(tables.contains(&"orders".to_string())); + } + + #[test] + fn test_extract_table_names_with_schema() { + let tables = UsageTracker::extract_table_names("SELECT * FROM public.users"); + assert_eq!(tables, vec!["public.users"]); + } + + #[test] + fn test_extract_column_names() { + let columns = UsageTracker::extract_column_names( + "SELECT user_id, username FROM users WHERE active = true" + ); + assert!(columns.contains(&"user_id".to_string())); + assert!(columns.contains(&"username".to_string())); + assert!(columns.contains(&"active".to_string())); + } + + #[test] + fn test_was_used_recently() { + let tracker = UsageTracker::new(10); + tracker.track_query("SELECT * FROM users"); + assert!(tracker.was_used_recently("users")); + assert!(!tracker.was_used_recently("orders")); + } + + #[test] + fn test_extract_column_names_with_keywords_out_of_order() { + // FROM before SELECT — should still extract columns after SELECT + let columns = UsageTracker::extract_column_names("from test select val"); + assert!(columns.contains(&"val".to_string())); + } + + #[test] + fn test_extract_column_names_edge_cases() { + // SELECT with no following clause — column should still be extracted + let columns = UsageTracker::extract_column_names("SELECT user_id"); + assert!(columns.contains(&"user_id".to_string())); + + // "SELECTFROM" is not a valid SELECT keyword (no space), so find("SELECT") + // still matches at position 0; rest = "FROM users", end = 0 (FROM at pos 0), + // between = "" → no columns extracted. + let columns = UsageTracker::extract_column_names("SELECTFROM users"); + assert_eq!(columns.len(), 0); + } +} diff --git a/src/context.rs b/src/context.rs index b9b868c..274aa73 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1,5 +1,11 @@ use crate::args::{get_url, Args}; +use crate::completion::usage_tracker::UsageTracker; +use crate::table_renderer::ParsedResult; +use crate::tui_msg::{TuiLine, TuiMsg}; use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::mpsc::UnboundedSender; +use tokio_util::sync::CancellationToken; #[derive(Clone, Debug, Deserialize, Serialize)] pub struct ServiceAccountToken { @@ -9,6 +15,7 @@ pub struct ServiceAccountToken { pub until: u64, } +#[derive(Clone)] pub struct Context { pub args: Args, pub url: String, @@ -16,12 +23,37 @@ pub struct Context { pub prompt1: Option, pub prompt2: Option, pub prompt3: Option, + pub last_result: Option, + pub last_stats: Option, + pub is_interactive: bool, + pub usage_tracker: Option>, + + /// When running inside the TUI, all output lines are sent here instead of + /// going to stdout/stderr. `None` in headless / non-interactive mode. + pub tui_output_tx: Option>, + + /// When running inside the TUI, this token can be cancelled by the user + /// (Ctrl+C) to abort an in-flight query. Replaces the SIGINT handler. + pub query_cancel: Option, } impl Context { pub fn new(args: Args) -> Self { let url = get_url(&args); - Self { args, url, sa_token: None, prompt1: None, prompt2: None, prompt3: None } + Self { + args, + url, + sa_token: None, + prompt1: None, + prompt2: None, + prompt3: None, + last_result: None, + last_stats: None, + is_interactive: false, + usage_tracker: None, + tui_output_tx: None, + query_cancel: None, + } } pub fn update_url(&mut self) { @@ -39,6 +71,99 @@ impl Context { pub fn set_prompt3(&mut self, prompt: String) { self.prompt3 = Some(prompt); } + + // ── Output helpers ────────────────────────────────────────────────────── + + /// Emit a line of normal output. In TUI mode: sends to the output channel. + /// In headless mode: prints to stdout. + pub fn emit(&self, text: impl AsRef) { + let text = text.as_ref(); + if let Some(tx) = &self.tui_output_tx { + let _ = tx.send(TuiMsg::Line(text.to_string())); + } else { + println!("{}", text); + } + } + + /// Emit a line of error/diagnostic output. In TUI mode: sends to the + /// output channel (same pane, different style applied by TuiApp). + /// In headless mode: prints to stderr. + pub fn emit_err(&self, text: impl AsRef) { + let text = text.as_ref(); + if let Some(tx) = &self.tui_output_tx { + let _ = tx.send(TuiMsg::Line(text.to_string())); + } else { + eprintln!("{}", text); + } + } + + /// Emit an empty line (equivalent to `println!()` / `eprintln!()`). + pub fn emit_newline(&self) { + if let Some(tx) = &self.tui_output_tx { + let _ = tx.send(TuiMsg::Line(String::new())); + } else { + eprintln!(); + } + } + + /// Emit raw multi-line text. In TUI mode each `\n` becomes a new output + /// line. In headless mode it is printed without an extra trailing newline. + pub fn emit_raw(&self, text: impl AsRef) { + let text = text.as_ref(); + if let Some(tx) = &self.tui_output_tx { + for line in text.split('\n') { + let _ = tx.send(TuiMsg::Line(line.to_string())); + } + } else { + print!("{}", text); + } + } + + /// Emit pre-styled lines (TUI only; no-op in headless mode — callers must + /// use a String-based fallback for non-TUI output). + pub fn emit_styled_lines(&self, lines: Vec) { + if let Some(tx) = &self.tui_output_tx { + let _ = tx.send(TuiMsg::StyledLines(lines)); + } + } + + /// Send the parsed result back to the TUI so it can be used by the csvlens viewer / export. + /// No-op in headless mode (caller already sets `context.last_result` directly). + pub fn emit_parsed_result(&self, result: &crate::table_renderer::ParsedResult) { + if let Some(tx) = &self.tui_output_tx { + let _ = tx.send(TuiMsg::ParsedResult(result.clone())); + } + } + + /// Returns `true` when an explicit transaction is active on this session. + /// + /// Detected by the presence of a `transaction_id` URL parameter, which the + /// server injects via `Firebolt-Update-Parameters` after `BEGIN` and removes + /// via `Firebolt-Reset-Session` / `Firebolt-Remove-Parameters` after + /// `COMMIT` or `ROLLBACK`. + pub fn in_transaction(&self) -> bool { + self.args.extra.iter().any(|e| e.starts_with("transaction_id=")) + } + + /// Returns `true` when running inside the TUI event loop. + pub fn is_tui(&self) -> bool { + self.tui_output_tx.is_some() + } + + /// Returns a clone with all server-managed transaction parameters removed. + /// + /// Use this when creating a context for internal queries (schema cache + /// refresh, setting validation) that must not run inside an open + /// transaction. + pub fn without_transaction(&self) -> Self { + let mut c = self.clone(); + c.args.extra.retain(|e| { + !e.starts_with("transaction_id=") + && !e.starts_with("transaction_sequence_id=") + }); + c.update_url(); + c + } } #[cfg(test)] @@ -56,5 +181,39 @@ mod tests { assert!(context.url.contains("localhost:8123")); assert!(context.url.contains("database=test_db")); assert!(context.sa_token.is_none()); + assert!(context.last_result.is_none()); + } + + #[test] + fn test_without_transaction_strips_txn_params() { + let mut args = crate::args::get_args().unwrap(); + args.extra = vec![ + "transaction_id=deadbeef".to_string(), + "transaction_sequence_id=3".to_string(), + "other_param=keep_me".to_string(), + ]; + let ctx = Context::new(args); + assert!(ctx.in_transaction()); + + let clean = ctx.without_transaction(); + + assert!(!clean.in_transaction()); + assert!(!clean.args.extra.iter().any(|e| e.starts_with("transaction_sequence_id="))); + assert!(clean.args.extra.iter().any(|e| e == "other_param=keep_me"), + "non-transaction extras must be preserved"); + // URL must reflect the stripped state + assert!(!clean.url.contains("transaction_id"), + "URL must not contain transaction_id after without_transaction"); + } + + #[test] + fn test_without_transaction_noop_when_no_txn() { + let mut args = crate::args::get_args().unwrap(); + args.extra = vec!["custom=value".to_string()]; + let ctx = Context::new(args); + assert!(!ctx.in_transaction()); + + let clean = ctx.without_transaction(); + assert_eq!(clean.args.extra, ctx.args.extra); } } diff --git a/src/docs/completion.md b/src/docs/completion.md new file mode 100644 index 0000000..4950a1c --- /dev/null +++ b/src/docs/completion.md @@ -0,0 +1,132 @@ +# SQL Auto-Completion + +Auto-completion is implemented as a pure in-memory system: no external process, no LSP. It uses a schema cache populated at session start and a priority scorer to surface the most relevant suggestion first. + +## Components + +| File | Struct | Role | +|------|--------|------| +| `completion/mod.rs` | `SqlCompleter` | Orchestrates completion; called by the REPL | +| `completion/schema_cache.rs` | `SchemaCache` | Caches DB metadata; refreshed async | +| `completion/priority_scorer.rs` | `PriorityScorer` | Scores suggestions by class + usage | +| `completion/usage_tracker.rs` | `UsageTracker` | Counts how often each item has been used | +| `completion/context_analyzer.rs` | `ContextAnalyzer` | Extracts table names from the current query | +| `completion/context_detector.rs` | `ContextDetector` | Classifies the cursor position (FROM, SELECT, etc.) | + +## Request flow + +``` +tui-textarea Tab keypress + │ + ▼ +SqlCompleter::complete_at(line, cursor_byte) + │ + ├── ContextDetector::detect() → CompletionContext + ├── ContextAnalyzer::extract_tables() → Vec (tables in query) + ├── SchemaCache::get_*() → candidate lists + └── PriorityScorer::score() → ranked Vec +``` + +The completer returns `(word_start_byte, Vec)`. The TUI replaces `line[word_start..]` up to the cursor with the selected candidate. + +## SchemaCache (`completion/schema_cache.rs`) + +Thread-safe (`Arc>`) cache of: +- Tables: `HashMap` keyed by `"schema.table"` +- Columns: per table, a `Vec` +- Functions: `Vec` +- SQL keywords: a hardcoded list of ~60 SQL keywords + +### Refresh + +`refresh()` is an async method that executes three queries against the connected database: + +```sql +SELECT table_schema, table_name FROM information_schema.tables; +SELECT table_schema, table_name, column_name, data_type FROM information_schema.columns; +SELECT routine_name FROM information_schema.routines WHERE routine_type != 'OPERATOR'; +``` + +Responses are parsed from `JSONLines_Compact` format. The refresh is debounced: a `refreshing` atomic flag prevents concurrent refreshes. + +On session start, `SchemaCache::refresh()` is called in a background `tokio::spawn` task so startup is non-blocking. + +**TTL**: configurable; defaults to 5 minutes. After the TTL expires, `is_stale()` returns true and `\refresh` or the next session start triggers a new fetch. Use `\refresh` in the REPL to force an immediate refresh. + +### Lookup methods + +| Method | Returns | +|--------|---------| +| `get_tables(prefix)` | Table names matching prefix | +| `get_tables_in_schema(schema, prefix)` | Tables in a specific schema | +| `get_tables_with_schema(prefix)` | `(schema, table)` tuples | +| `get_columns(table, prefix)` | Column names for a table | +| `get_columns_with_table(prefix)` | `(table, column)` tuples | +| `get_schemas(prefix)` | Schema names | +| `get_functions(prefix)` | Function names | +| `get_keywords(prefix)` | SQL keywords | + +All methods take a `prefix: &str` and return only items that start with that prefix (case-insensitive). + +## SqlCompleter (`completion/mod.rs`) + +`complete_at(line, cursor_byte)` is the single entry point: + +1. Extract the partial word before the cursor. +2. Detect completion context (FROM, SELECT, WHERE, etc.) using `ContextDetector`. +3. If the partial word contains `.`, handle schema-qualified or table-qualified completion: + - `schema.` → complete table names in that schema + - `table.` → complete column names in that table +4. Otherwise, generate candidates from all applicable categories (schemas, tables, columns from in-scope tables, functions, keywords). +5. Score every candidate with `PriorityScorer::score()`. +6. Sort descending by score, deduplicate, return. + +### Disabling completion + +`SqlCompleter::set_enabled(false)` makes `complete_at()` return an empty list immediately. Controlled at runtime with `set completion = off;`. + +## PriorityScorer (`completion/priority_scorer.rs`) + +Assigns a numeric score to each candidate. Higher is better. Score = **priority class** + **usage bonus**. + +| Priority class | Score | Applied to | +|----------------|-------|------------| +| 5 | 5 000 | Columns from tables already in the current query (context columns) | +| 4 | 4 000 | Schemas | +| 3 | 3 000 | Tables | +| 2 | 2 000 | Functions | +| 1 | 1 000 | Keywords | +| 0 | 0 | System schemas / system tables | + +Usage bonus: each item used in the last 10 queries adds `100 × frequency` to its score (capped so it can't surpass the next priority class). + +System objects (`information_schema`, `pg_catalog`, `pg_*` tables) are always assigned priority class 0, keeping them at the bottom of any suggestion list. + +## UsageTracker (`completion/usage_tracker.rs`) + +Tracks how many times each table or column has appeared in recently-executed queries. Stored as a sliding window of the last N queries (default: 10). Usage counts decay naturally as old queries fall out of the window. + +`track_query(sql)` is called after each successful query execution (in `query.rs`). It extracts identifiers from the SQL string and increments their counts. + +`get_usage(item) -> u32` returns the count, used by `PriorityScorer`. + +## ContextAnalyzer and ContextDetector + +`ContextAnalyzer::extract_tables(sql)` scans the SQL text for table references (FROM clause, JOIN clauses) using regex patterns. Returns a `Vec` of table names visible in the current statement. These are passed to `PriorityScorer` to identify context columns (priority class 5). + +`ContextDetector::detect(sql, cursor_pos)` determines which SQL clause the cursor sits in. The result (`CompletionContext`) influences which candidate categories are included: + +| Context | Categories offered | +|---------|--------------------| +| `FromClause` | Tables, schemas | +| `SelectClause` | Columns (context-aware), functions, keywords | +| `WhereClause` | Columns, functions, keywords | +| `JoinClause` | Tables, schemas | +| `Unknown` | All categories | + +## Adding new completion sources + +1. Add a `get_*()` method to `SchemaCache` returning candidates for the new category. +2. Add a priority class constant to `PriorityScorer` if the category needs a distinct ranking tier. +3. Call the new method from `SqlCompleter::complete_at()` and pass the results through `PriorityScorer::score()`. +4. If the source requires new schema queries, add them to `SchemaCache::refresh()`. diff --git a/src/docs/output_pipeline.md b/src/docs/output_pipeline.md new file mode 100644 index 0000000..b084048 --- /dev/null +++ b/src/docs/output_pipeline.md @@ -0,0 +1,103 @@ +# Output Pipeline + +This document describes how output travels from business logic (query execution, meta-commands) to the visible text in the TUI output pane, and how the output pane renders it. + +## Overview + +``` +query() / meta_commands() + │ + │ Context::emit*() + ▼ + mpsc::UnboundedSender + │ + │ TuiApp::drain_query_output() (100ms tick) + ▼ + OutputPane::push_*() + │ + │ OutputPane::render() + ▼ + ratatui Buffer → terminal +``` + +## TuiMsg (`src/tui_msg.rs`) + +The channel carries `TuiMsg`, a two-variant enum: + +```rust +pub enum TuiMsg { + Line(String), // plain text (may contain ANSI escape codes) + StyledLines(Vec), // pre-styled table output +} +``` + +`TuiLine` is a `Vec`. `TuiSpan` holds a `String`, a `TuiColor`, and a `bold` flag. This type lives in `tui_msg.rs` and has **no dependency on ratatui** so it can be constructed inside the query task (which also runs in headless mode and must not import TUI types). + +The conversion from `TuiSpan` → ratatui `Span<'static>` happens in `output_pane.rs::tui_span_to_ratatui()`. + +### Why two variants? + +Tables need per-span color (header cyan, NULL dark gray, border plain). Encoding this as ANSI escape codes and then parsing them back is fragile and depends on the rendering library emitting codes unconditionally (comfy-table does not when stdout is not a TTY). `StyledLines` bypasses ANSI entirely by carrying the styling as structured data. + +Plain text output (errors, stats, `\help` text, etc.) is sent as `Line(String)`. It may optionally contain ANSI SGR codes from other sources; those are parsed by `parse_ansi_line()` in the output pane. + +## Context output helpers (`src/context.rs`) + +All output in business-logic code goes through these methods on `Context`: + +| Method | Headless | TUI | +|--------|----------|-----| +| `emit(text)` | `println!` | `TuiMsg::Line` | +| `emit_err(text)` | `eprintln!` | `TuiMsg::Line` | +| `emit_newline()` | `eprintln!()` | `TuiMsg::Line("")` | +| `emit_raw(text)` | `print!` (no trailing newline) | splits on `\n`, sends each as `TuiMsg::Line` | +| `emit_styled_lines(lines)` | no-op | `TuiMsg::StyledLines` | + +`is_tui()` returns `true` when `tui_output_tx` is `Some`. Query code that needs different behaviour in TUI mode (e.g. calling `emit_table_tui()` instead of `render_table_output()`) uses this. + +## drain_query_output (`tui/mod.rs`) + +Called once per event-loop tick before rendering. Drains all pending messages non-blockingly: + +- `TuiMsg::StyledLines` → `output.push_tui_lines()` +- `TuiMsg::Line` starting with `"Time: "` or `"Scanned: "` → `output.push_stat()` +- `TuiMsg::Line` starting with `"Error: "` or `"^C"` → `output.push_error()` +- `TuiMsg::Line` starting with `"Showing first "` while running → captured as `running_hint`, not forwarded +- Everything else → `output.push_ansi_text()` + +Channel `Disconnected` signals that the query task has finished; `is_running` is cleared. + +## OutputPane (`tui/output_pane.rs`) + +Stores all output as a `Vec`. Each `OutputLine` holds a `Line<'static>` — a ratatui line of styled spans — that has already been fully resolved. There is no late-stage style application during render; all conversion happens in the `push_*` methods. + +### Push methods + +| Method | Style applied | +|--------|---------------| +| `push_line(s)` | No style (default terminal color) | +| `push_prompt(s)` | `❯ ` prefix: green+bold; remainder: yellow | +| `push_stat(s)` | Dark gray; splits on `\n` | +| `push_error(s)` | Red; splits on `\n` | +| `push_ansi_text(s)` | Parses ANSI SGR codes; splits on `\n` | +| `push_tui_lines(lines)` | Converts `TuiLine` → ratatui `Line` via `tui_span_to_ratatui()` | + +All push methods call `scroll_to_bottom()` (sets `scroll = usize::MAX` as a sentinel), which is resolved to `total_lines - visible_height` in `clamp_scroll()` before rendering. + +### Scrolling + +`scroll_up(n)` / `scroll_down(n)` adjust `scroll` by `n` lines. `clamp_scroll(height)` is called before each render to keep the offset in bounds and to resolve the `usize::MAX` sentinel. + +### Rendering + +`render(area, buf)` renders only the visible slice `lines[scroll..scroll+height]`. Content is **bottom-anchored**: empty `Line::raw("")` padding is prepended so that when there are fewer lines than the visible height, the content sticks to the bottom of the pane (the output "grows upward" from the input area, like a normal terminal). + +```rust +let padding = height.saturating_sub(content.len()); +let visible = repeat_n(Line::raw(""), padding).chain(content).collect(); +Widget::render(Paragraph::new(visible), area, buf); +``` + +### ANSI SGR parser + +`parse_ansi_line(text)` walks the raw byte string looking for `ESC [` sequences terminated by `m`. It applies `apply_sgr()` to build a running `Style` and flushes text segments as `Span::styled(text, current_style)`. Unrecognized SGR parameters are silently ignored. This parser handles output from non-table sources (e.g. `\help`, comfy-table in headless mode) that may have been formatted with ANSI codes before being written to the channel. diff --git a/src/docs/table_rendering.md b/src/docs/table_rendering.md new file mode 100644 index 0000000..8092240 --- /dev/null +++ b/src/docs/table_rendering.md @@ -0,0 +1,139 @@ +# Table Rendering + +fb-cli has two rendering paths for query results: + +| Path | When used | Output type | +|------|-----------|-------------| +| **Headless** (`render_table*`) | Non-TUI mode | ANSI string written to stdout | +| **TUI** (`render_*_tui_lines`) | TUI mode | `Vec` sent via `TuiMsg::StyledLines` | + +The TUI path produces `TuiLine`/`TuiSpan` values directly, bypassing ANSI entirely. Both paths are in `src/table_renderer.rs`. + +## Entry point in query.rs + +```rust +if context.is_tui() { + emit_table_tui(context, columns, rows, terminal_width, max_cell); +} else { + let rendered = render_table_output(context, columns, rows, terminal_width, max_cell); + out!(context, "{}", rendered); +} +``` + +`emit_table_tui()` subtracts 1 from `terminal_width` before passing it to the renderer. This leaves a 1-column right margin so the table's closing border is never clipped by ratatui's render area. + +## Display modes + +Controlled by `context.args`: + +| `args` method | TUI renderer called | +|---------------|---------------------| +| `is_vertical_display()` | `render_vertical_table_to_tui_lines()` | +| `is_horizontal_display()` | `render_horizontal_forced_tui_lines()` | +| auto (default) | `render_table_to_tui_lines()` — decides at runtime | + +In auto mode, `decide_col_widths()` returns `Some(widths)` for horizontal or `None` to fall back to vertical. + +## Cell value formatting (`fmt_cell`) + +Before layout, every cell value is processed by `fmt_cell(val, max_value_length)`: + +1. `format_value(val)` converts the `serde_json::Value` to a display string: + - `Null` → `"NULL"` + - `String(s)` → `s` (as-is, preserving the value) + - `Number` / `Bool` → `.to_string()` + - `Array` / `Object` → JSON-serialized, hard-capped at 1 000 chars with `... (truncated)` suffix + +2. All control characters (`\n`, `\r`, `\t`, etc.) are replaced with spaces. This is critical: cells that contain multi-line text (e.g. `query_text` storing SQL with newlines) would otherwise produce spans containing `\n`, which ratatui renders as line breaks, causing misaligned borders. + +3. `truncate_to_chars(s, max_value_length)` caps the result at `max_value_length` Unicode scalar values, appending `"..."` if truncated. The limit is `max_cell_length` from args (default: 1 000); for single-column queries it is raised to 5× the normal limit. + +## Auto layout: `decide_col_widths` + +Accepts the formatted cell strings and the available terminal width. Returns `Some(col_widths)` for horizontal layout or `None` for vertical. + +### Available space calculation + +``` +overhead = 3 * n + 1 (for "│ col₀ │ col₁ │ … │" structure) +available = terminal_width - overhead +``` + +### Decision sequence + +1. **Single-column shortcut** — always horizontal, width = `min(terminal_width - 4, natural_capped)`. + +2. **Natural widths** — for each column: `max(header_chars, max_content_chars)`, capped at `terminal_width * 4/5`. + - If `Σ natural_widths ≤ available`: use natural widths as-is. + +3. **Too cramped?** — if `available / n < 10`: return `None` (vertical). + +4. **Minimum widths** — per column: `max(10, ⌈header_len/2⌉, ⌈√max_content_len⌉)`. + - `⌈header_len/2⌉` ensures the header needs at most 2 wrap lines. + - `⌈√max_content_len⌉` keeps cells roughly square (height ≈ width). + - If `Σ min_widths > available`: return `None` (vertical). + +5. **Space distribution** — start with `min_widths`, repeatedly add 1 to each column that hasn't reached its natural width until `available` is exhausted. + +6. **Tall-cell guard** — after distribution, compute how many wrap rows each cell needs: `⌈content_chars / col_width⌉`. If any cell needs more rows than there are columns (`n`), return `None` (vertical). This prevents a single long cell from producing a table taller than it is wide. + +## Horizontal rendering (`render_horizontal_tui`) + +Uses Unicode box-drawing characters: + +``` +┌──────────────┬──────────┐ top border +│ account_name │ value │ header row (cyan + bold) +╞══════════════╪══════════╡ header separator +│ firebolt │ 42 │ data row +├──────────────┼──────────┤ row separator (only when wrapping occurs) +│ other-acct │ 0 │ +└──────────────┴──────────┘ bottom border +``` + +Row separators (`├─┼─┤`) are only added between rows when **any** cell in the table wraps to more than one visual line. When all cells fit on one line, no separators are drawn. + +### `wrap_cell(s, width) -> Vec` + +Splits a string into chunks of exactly `width` Unicode scalar values, padding the last chunk with spaces. Each chunk is suitable for direct use as a ratatui span because: +- All chunks are exactly `width` chars wide (measured by `chars().count()`) +- No control characters exist (sanitized by `fmt_cell`) + +### Border construction (`make_tui_border`) + +Each column occupies `col_width + 2` fill characters between separator characters, matching the `" col_content "` padding in data rows. + +### NULL styling + +NULL cells are rendered in dark gray (`TuiColor::DarkGray`) to visually distinguish them from the empty-string value. + +## Vertical rendering (`render_vertical_table_to_tui_lines`) + +Renders each row as its own mini two-column table: + +``` +Row 1: ← cyan + bold label +┌──────────────┬──────────┐ +│ account_name │ firebolt │ +│ value │ 42 │ +└──────────────┴──────────┘ + ← blank line between rows +Row 2: +… +``` + +Column widths: +- Name column: `max(header_names_len, 30)`, floor 10 +- Value column: `terminal_width - name_col - 7` (7 = overhead for two-column table), floor 10 + +Row separators between fields within a mini-table follow the same rule as horizontal: only drawn when any name or value wraps. + +## Forced-horizontal rendering (`render_horizontal_forced_tui_lines`) + +Calls `decide_col_widths()` first. If it returns `None` (would normally go vertical), falls back to equal-share widths: `available / n` per column (minimum 1). This ensures a horizontal table is always produced regardless of content width. + +## Headless rendering + +`render_table()` uses [comfy-table](https://github.com/Numesson/comfy-table) for non-TUI output. comfy-table does **not** emit ANSI codes when stdout is not a TTY, so the TUI path was built from scratch rather than trying to reuse it. + +`render_table_vertical()` renders a simple two-column plain-text table using `format!` strings without a dependency on comfy-table. diff --git a/src/docs/tui.md b/src/docs/tui.md new file mode 100644 index 0000000..1db8346 --- /dev/null +++ b/src/docs/tui.md @@ -0,0 +1,126 @@ +# TUI Architecture + +fb-cli's interactive REPL is built on [ratatui](https://github.com/ratatui-org/ratatui) with [tui-textarea](https://github.com/rhysd/tui-textarea) for the editor widget and [crossterm](https://github.com/crossterm-rs/crossterm) as the terminal backend. + +## Layout + +``` +┌───────────────────────────────────────────────┐ +│ OUTPUT PANE (scrollable, fills remaining │ +│ space; bottom-anchored: new output grows │ +│ upward from the input area) │ +│ │ +├───────────────────────────────────────────────┤ +│ INPUT PANE (tui-textarea, 1–5 lines visible │ +│ + DarkGray top/bottom borders) │ +├───────────────────────────────────────────────┤ +│ STATUS BAR (1 line, host | db | version) │ +└───────────────────────────────────────────────┘ +``` + +The layout is computed by `tui/layout.rs::compute_layout()`. It reserves a fixed block of rows at the bottom (`RESERVED_INPUT = 5`) so the output pane doesn't jump when the textarea grows to a second line. A transparent spacer is inserted between the input pane and the status bar to absorb the reserved space. When the textarea grows past 5 lines, it starts pushing the output pane upward. + +## Key structs + +| Struct | File | Purpose | +|--------|------|---------| +| `TuiApp` | `tui/mod.rs` | Owns all state; drives the event loop | +| `OutputPane` | `tui/output_pane.rs` | Scrollable output history | +| `History` | `tui/history.rs` | File-backed history with cursor navigation | +| `AppLayout` | `tui/layout.rs` | Three `Rect` values from layout computation | +| `TuiMsg` | `tui_msg.rs` | Channel message type between query task and TUI | + +## Event loop + +`TuiApp::run()` sets up the terminal (raw mode, alternate screen, mouse capture, Kitty keyboard protocol) then delegates to `event_loop()`. + +``` +loop: + 1. drain_query_output() — pull pending TuiMsg items off the channel + 2. increment spinner tick (if query is running) + 3. terminal.draw(render) — paint the frame + 4. event::poll(100ms) — wait for input; tick the spinner even when idle + 5. handle_key() or scroll mouse event +``` + +The 100 ms poll timeout drives the spinner animation without consuming CPU. + +## Query execution + +Queries run in a `tokio::spawn` task, fully asynchronous with respect to the render loop. Communication happens through two primitives: + +- **`mpsc::unbounded_channel::()`** – output from the query flows back to the TUI. When the task finishes (or is cancelled), it drops `ctx`, which drops the sender, which closes the channel. `drain_query_output()` detects the `Disconnected` variant and clears `is_running`. +- **`CancellationToken`** – Ctrl+C cancels the query by calling `token.cancel()`. The query function polls this token and aborts the HTTP request. + +The query task receives a cloned `Context` with `tui_output_tx` and `query_cancel` populated. All output is routed through `Context::emit*()` helpers rather than `println!`, so the same query code works in both headless and TUI modes. + +## Running pane + +While a query is in flight the input textarea is replaced by a compact "running pane" showing a Braille spinner and elapsed time. The `running_hint` string (e.g. "Showing first N rows — collecting remainder...") is also displayed if present. This hint is intercepted from the `TuiMsg::Line` stream in `drain_query_output()` — lines that start with `"Showing first "` are captured in `running_hint` rather than forwarded to the output pane. + +## Key bindings + +| Key | Action | +|-----|--------| +| `Enter` | Submit query if SQL is syntactically complete; otherwise insert newline | +| `Shift+Enter` | Always insert newline (requires Kitty keyboard protocol) | +| `Ctrl+D` | Exit | +| `Ctrl+C` | Cancel input (idle) or cancel in-flight query (running) | +| `Ctrl+V` | Open last result in csvlens viewer | +| `Up` / `Ctrl+Up` | History: navigate backward (Up at row 0 triggers history) | +| `Down` / `Ctrl+Down` | History: navigate forward | +| `PageUp` / `PageDown` | Scroll output pane by 10 lines | +| Mouse scroll | Scroll output pane by 8 lines | +| `\view` + Enter | Open csvlens viewer | +| `\refresh` + Enter | Refresh schema cache | +| `\help` + Enter | Show help in output pane | + +### Kitty keyboard protocol + +`PushKeyboardEnhancementFlags(DISAMBIGUATE_ESCAPE_CODES)` is sent on startup so terminals that support it (kitty, WezTerm, foot, etc.) send distinct codes for `Shift+Enter` vs `Enter`. On terminals that don't support it the flag push is silently ignored; `Shift+Enter` falls through to inserting a newline via the textarea's default handling. + +### Query submission heuristic + +The TUI calls `try_split_queries()` from `query.rs`, which uses the Pest SQL grammar to split on semicolons while respecting strings, comments, and dollar-quoted blocks. If the text splits into at least one complete query the whole buffer is submitted. If not (e.g. the user is mid-statement), a newline is inserted instead. + +## Rendering pipeline + +``` +TuiApp::render() + ├── compute_layout(area, input_height) + ├── output.clamp_scroll(layout.output.height) + ├── output.render(layout.output, buf) ← OutputPane + ├── render_running_pane() OR render_input() ← tui-textarea / spinner + └── render_status_bar() +``` + +Colors used: +- Output pane: prompt `❯` = green+bold, SQL text = yellow, stats = dark gray, errors = red, table headers = cyan+bold, NULL values = dark gray +- Input pane: `❯` prompt = green, border = dark gray +- Status bar: dark gray background, white text, right side shows `Ctrl+C cancel` while running + +## History (`tui/history.rs`) + +`History` stores entries in memory and appends each new entry to `~/.firebolt/fb_history` (one entry per line). Multi-line entries are stored with embedded newlines escaped as `\\n`. + +Navigation state: +- `cursor: Option` — `None` means not navigating. `Some(i)` means showing `entries[i]`. +- `saved_current: String` — the text that was in the editor when the user first pressed Up, restored when they press Down past the newest entry. + +Consecutive duplicate entries are not stored. The in-memory list is capped at 10 000 entries; the file grows unboundedly (trimming is applied on load). + +## csvlens integration + +`\view` (or Ctrl+V) calls `open_viewer()`, which: +1. `disable_raw_mode()` + `LeaveAlternateScreen` — yields the terminal +2. Spawns `csvlens` as a subprocess via `viewer::open_csvlens_viewer()` +3. `enable_raw_mode()` + `EnterAlternateScreen` — reclaims the terminal +4. Sets `needs_clear = true` so ratatui does a full repaint on the next tick + +## Adding new key bindings + +Add a match arm in `handle_key()` in `tui/mod.rs`. For bindings that only apply while running, add them in the early return block at the top of `handle_key()`. + +## Adding new backslash commands + +Add a match arm in `handle_backslash_command()`. Persistent parameter changes (e.g. `set format = ...`) should go through `handle_meta_command()` in `meta_commands.rs` so they work in both TUI and headless mode. diff --git a/src/highlight.rs b/src/highlight.rs new file mode 100644 index 0000000..6ea9f3b --- /dev/null +++ b/src/highlight.rs @@ -0,0 +1,395 @@ +use once_cell::sync::Lazy; +use ratatui::style::{Color, Style}; +use regex::Regex; +use std::ops::Range; + +/// Color scheme using ANSI escape codes (for headless/non-TUI output) +#[allow(dead_code)] +pub struct ColorScheme { + keyword: &'static str, + function: &'static str, + string: &'static str, + number: &'static str, + comment: &'static str, + operator: &'static str, + reset: &'static str, +} + +impl ColorScheme { + fn new() -> Self { + Self { + keyword: "\x1b[94m", // Bright Blue + function: "\x1b[96m", // Bright Cyan + string: "\x1b[93m", // Bright Yellow + number: "\x1b[95m", // Bright Magenta + comment: "\x1b[90m", // Bright Black (gray) + operator: "\x1b[0m", // Default/Reset + reset: "\x1b[0m", // Reset All + } + } +} + +/// Ratatui styles for TUI syntax highlighting — mirrors the ANSI escape codes used in +/// headless mode so the TUI and terminal output look identical. +fn keyword_style() -> Style { + Style::default().fg(Color::LightBlue) // \x1b[94m Bright Blue +} +fn function_style() -> Style { + Style::default().fg(Color::LightCyan) // \x1b[96m Bright Cyan +} +fn string_style() -> Style { + Style::default().fg(Color::LightYellow) // \x1b[93m Bright Yellow +} +fn number_style() -> Style { + Style::default().fg(Color::LightMagenta) // \x1b[95m Bright Magenta +} +fn comment_style() -> Style { + Style::default().fg(Color::DarkGray) // \x1b[90m Dark Gray +} + +// SQL Keywords pattern +static KEYWORD_PATTERN: Lazy = Lazy::new(|| { + Regex::new(r"(?i)\b(SELECT|FROM|WHERE|JOIN|INNER|LEFT|RIGHT|OUTER|FULL|ON|AND|OR|NOT|IN|IS|NULL|LIKE|BETWEEN|GROUP|BY|HAVING|ORDER|ASC|DESC|LIMIT|OFFSET|UNION|INTERSECT|EXCEPT|INSERT|INTO|VALUES|UPDATE|SET|DELETE|CREATE|ALTER|DROP|TABLE|VIEW|INDEX|AS|DISTINCT|ALL|CASE|WHEN|THEN|ELSE|END|WITH|RECURSIVE|RETURNING|CAST|EXTRACT|INTERVAL|EXISTS|PRIMARY|KEY|FOREIGN|REFERENCES|CONSTRAINT|DEFAULT|UNIQUE|CHECK|CROSS)\b").unwrap() +}); + +// SQL Functions pattern +static FUNCTION_PATTERN: Lazy = Lazy::new(|| { + Regex::new(r"(?i)\b(COUNT|SUM|AVG|MIN|MAX|COALESCE|NULLIF|CONCAT|SUBSTRING|LENGTH|UPPER|LOWER|TRIM|ROUND|FLOOR|CEIL|ABS|NOW|CURRENT_DATE|CURRENT_TIME|CURRENT_TIMESTAMP|DATE_TRUNC|EXTRACT)\s*\(").unwrap() +}); + +// String pattern (single quotes with escape sequences) +static STRING_PATTERN: Lazy = Lazy::new(|| Regex::new(r"'(?:[^'\\]|''|\\.)*'").unwrap()); + +// Number pattern +static NUMBER_PATTERN: Lazy = Lazy::new(|| Regex::new(r"\b\d+\.?\d*([eE][+-]?\d+)?\b").unwrap()); + +// Comment patterns (applied to full multi-line text) +static LINE_COMMENT_PATTERN: Lazy = Lazy::new(|| Regex::new(r"--[^\n]*").unwrap()); +static BLOCK_COMMENT_PATTERN: Lazy = Lazy::new(|| Regex::new(r"/\*[\s\S]*?\*/").unwrap()); + +// Operator pattern +#[allow(dead_code)] +static OPERATOR_PATTERN: Lazy = Lazy::new(|| Regex::new(r"[=<>!]+|[+\-*/%]|\|\||::").unwrap()); + +/// Holds a non-overlapping list of (start, end, priority) spans. +/// Higher priority wins when two spans overlap. +struct SpanList { + entries: Vec<(usize, usize, u8)>, // (start, end, priority) +} + +impl SpanList { + fn new() -> Self { + Self { entries: Vec::new() } + } + + fn add(&mut self, start: usize, end: usize, priority: u8) { + self.entries.push((start, end, priority)); + } + + /// Returns the highest-priority span that contains `pos`, if any. + fn style_at(&self, pos: usize) -> Option { + self.entries + .iter() + .filter(|(s, e, _)| pos >= *s && pos < *e) + .max_by_key(|(_, _, p)| *p) + .map(|(_, _, p)| *p) + } + + /// Collect non-overlapping byte ranges → (start, end, priority), sorted by start. + /// When multiple spans overlap the same byte, the highest priority wins. + fn into_sorted_ranges(self) -> Vec<(usize, usize, u8)> { + if self.entries.is_empty() { + return vec![]; + } + // Determine overall extent + let max_end = self.entries.iter().map(|(_, e, _)| *e).max().unwrap_or(0); + let mut result: Vec<(usize, usize, u8)> = Vec::new(); + let mut i = 0usize; + while i < max_end { + let p = self.style_at(i); + match p { + None => i += 1, + Some(prio) => { + // Find how far this priority spans continuously + let start = i; + while i < max_end && self.style_at(i) == Some(prio) { + i += 1; + } + result.push((start, i, prio)); + } + } + } + result + } +} + +// Map priority numbers to tokens +const PRIO_COMMENT: u8 = 5; +const PRIO_STRING: u8 = 4; +const PRIO_FUNCTION: u8 = 3; +const PRIO_KEYWORD: u8 = 2; +const PRIO_NUMBER: u8 = 1; + +fn prio_to_ratatui_style(prio: u8) -> Style { + match prio { + PRIO_COMMENT => comment_style(), + PRIO_STRING => string_style(), + PRIO_FUNCTION => function_style(), + PRIO_KEYWORD => keyword_style(), + PRIO_NUMBER => number_style(), + _ => Style::default(), + } +} + +#[allow(dead_code)] +fn prio_to_ansi_color<'a>(prio: u8, scheme: &'a ColorScheme) -> &'a str { + match prio { + PRIO_COMMENT => scheme.comment, + PRIO_STRING => scheme.string, + PRIO_FUNCTION => scheme.function, + PRIO_KEYWORD => scheme.keyword, + PRIO_NUMBER => scheme.number, + _ => scheme.reset, + } +} + +/// Compute raw highlight ranges for `text` (multi-line OK). +/// Returns a sorted list of non-overlapping `(byte_range, priority)` pairs. +fn compute_ranges(text: &str) -> Vec<(Range, u8)> { + let mut spans = SpanList::new(); + + for mat in LINE_COMMENT_PATTERN.find_iter(text) { + spans.add(mat.start(), mat.end(), PRIO_COMMENT); + } + for mat in BLOCK_COMMENT_PATTERN.find_iter(text) { + spans.add(mat.start(), mat.end(), PRIO_COMMENT); + } + for mat in STRING_PATTERN.find_iter(text) { + spans.add(mat.start(), mat.end(), PRIO_STRING); + } + for mat in FUNCTION_PATTERN.find_iter(text) { + // Don't include the opening paren in the function name span + spans.add(mat.start(), mat.end() - 1, PRIO_FUNCTION); + } + for mat in KEYWORD_PATTERN.find_iter(text) { + spans.add(mat.start(), mat.end(), PRIO_KEYWORD); + } + for mat in NUMBER_PATTERN.find_iter(text) { + spans.add(mat.start(), mat.end(), PRIO_NUMBER); + } + + spans + .into_sorted_ranges() + .into_iter() + .map(|(s, e, p)| (s..e, p)) + .collect() +} + +/// SQL syntax highlighter. +/// Produces ANSI-escaped strings for headless display and ratatui `Style` spans for TUI. +pub struct SqlHighlighter { + #[allow(dead_code)] + color_scheme: ColorScheme, + enabled: bool, +} + +impl SqlHighlighter { + pub fn new(enabled: bool) -> Result> { + Ok(Self { + color_scheme: ColorScheme::new(), + enabled, + }) + } + + /// Highlight SQL text by applying ANSI color codes (headless / non-TUI mode). + #[allow(dead_code)] + pub fn highlight_sql(&self, line: &str) -> String { + if !self.enabled || line.is_empty() { + return line.to_string(); + } + + let ranges = compute_ranges(line); + let scheme = &self.color_scheme; + let mut result = String::with_capacity(line.len() * 2); + let mut last = 0usize; + + for (range, prio) in &ranges { + if range.start > last { + result.push_str(&line[last..range.start]); + } + result.push_str(prio_to_ansi_color(*prio, scheme)); + result.push_str(&line[range.start..range.end]); + result.push_str(scheme.reset); + last = range.end; + } + if last < line.len() { + result.push_str(&line[last..]); + } + result + } + + /// Return byte-range → ratatui `Style` spans for the given (possibly multi-line) SQL text. + /// Spans are non-overlapping and sorted by start offset. + pub fn highlight_to_spans(&self, text: &str) -> Vec<(Range, Style)> { + if !self.enabled || text.is_empty() { + return vec![]; + } + compute_ranges(text) + .into_iter() + .map(|(range, prio)| (range, prio_to_ratatui_style(prio))) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_disabled_highlighter() { + let highlighter = SqlHighlighter::new(false).unwrap(); + let result = highlighter.highlight_sql("SELECT * FROM users"); + assert_eq!(result, "SELECT * FROM users"); + } + + #[test] + fn test_keyword_highlighting() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("SELECT"); + assert!(result.contains("\x1b[94m")); + assert!(result.contains("SELECT")); + assert!(result.contains("\x1b[0m")); + } + + #[test] + fn test_string_highlighting() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("SELECT 'hello'"); + assert!(result.contains("\x1b[93m")); + } + + #[test] + fn test_number_highlighting() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("SELECT 42"); + assert!(result.contains("\x1b[95m")); + } + + #[test] + fn test_comment_highlighting() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("-- comment"); + assert!(result.contains("\x1b[90m")); + } + + #[test] + fn test_function_highlighting() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("SELECT COUNT(*)"); + assert!(result.contains("\x1b[96m")); + } + + #[test] + fn test_complex_query() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let query = "SELECT id, name FROM users WHERE status = 'active'"; + let result = highlighter.highlight_sql(query); + assert!(result.contains("\x1b[94m")); + assert!(result.contains("\x1b[93m")); + assert!(result.contains("\x1b[0m")); + } + + #[test] + fn test_keywords_in_strings_not_highlighted() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("SELECT 'SELECT FROM WHERE'"); + // Only the outer SELECT keyword should be highlighted; the ones inside the string should not + let keyword_count = result.matches("\x1b[94m").count(); + assert_eq!(keyword_count, 1); + } + + #[test] + fn test_malformed_sql_graceful() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("SELECT FROM WHERE"); + assert!(result.contains("\x1b[94m")); + } + + #[test] + fn test_empty_string() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql(""); + assert_eq!(result, ""); + } + + #[test] + fn test_multiline_fragment() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("FROM users"); + assert!(result.contains("\x1b[94m")); + } + + #[test] + fn test_operators() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("WHERE x = 1 AND y > 2"); + assert!(result.contains("\x1b[94m")); + assert!(result.contains("=")); + assert!(result.contains(">")); + } + + #[test] + fn test_block_comment() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("SELECT /* comment */ 1"); + assert!(result.contains("\x1b[90m")); + } + + #[test] + fn test_highlight_to_spans_disabled() { + let h = SqlHighlighter::new(false).unwrap(); + let spans = h.highlight_to_spans("SELECT 1"); + assert!(spans.is_empty()); + } + + #[test] + fn test_highlight_to_spans_keywords() { + let h = SqlHighlighter::new(true).unwrap(); + let spans = h.highlight_to_spans("SELECT 1 FROM t"); + // Should have spans for SELECT and FROM + assert!(!spans.is_empty()); + // SELECT is at offset 0..6 + let select_span = spans.iter().find(|(r, _)| r.start == 0 && r.end == 6); + assert!(select_span.is_some()); + } + + #[test] + fn test_highlight_to_spans_multiline() { + let h = SqlHighlighter::new(true).unwrap(); + let sql = "SELECT id\nFROM orders"; + let spans = h.highlight_to_spans(sql); + assert!(!spans.is_empty()); + // Should have spans for both SELECT and FROM + let has_select = spans.iter().any(|(r, _)| r.start == 0 && r.end == 6); + let from_offset = sql.find("FROM").unwrap(); + let has_from = spans.iter().any(|(r, _)| r.start == from_offset); + assert!(has_select); + assert!(has_from); + } + + #[test] + fn test_highlight_to_spans_no_overlap() { + let h = SqlHighlighter::new(true).unwrap(); + let sql = "SELECT id, name FROM orders WHERE id = 1 AND name = 'Alice'"; + let spans = h.highlight_to_spans(sql); + // Ensure spans are non-overlapping and sorted + for i in 1..spans.len() { + assert!( + spans[i].0.start >= spans[i - 1].0.end, + "Spans overlap: {:?} and {:?}", + spans[i - 1].0, + spans[i].0 + ); + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..c324cf2 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,182 @@ +use std::ffi::CStr; +use std::io::IsTerminal; +use std::os::raw::{c_char, c_int}; +use std::sync::Arc; + +pub mod args; +pub mod auth; +pub mod transport; +pub mod completion; +pub mod context; +pub mod highlight; +pub mod meta_commands; +pub mod query; +pub mod table_renderer; +pub mod tui; +pub mod tui_msg; +pub mod utils; +pub mod viewer; + +use args::get_args_from; +use gumdrop::Options as _; +use auth::maybe_authenticate; +use completion::schema_cache::SchemaCache; +use completion::usage_tracker::UsageTracker; +use context::Context; +use query::{ErrorKind, QueryFailed, dot_command, query}; +use tui::TuiApp; + +pub const CLI_VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const USER_AGENT: &str = concat!("fdb-cli/", env!("CARGO_PKG_VERSION")); +pub const FIREBOLT_PROTOCOL_VERSION: &str = "2.4"; + +/// Run the CLI with the given argv (raw[0] is the program name). +/// Returns the process exit code. +pub async fn run(raw_args: Vec) -> i32 { + let args = match get_args_from(&raw_args) { + Ok(a) => a, + Err(e) => { eprintln!("Error: {}", e); return ErrorKind::SystemError as i32; } + }; + + if args.version { + println!("fb-cli version {}", CLI_VERSION); + return 0; + } + + if args.help { + let prog = raw_args.first().map(String::as_str).unwrap_or("fb"); + println!("Usage: {prog} [OPTIONS] [QUERY...]"); + println!(); + println!("{}", args::Args::usage()); + return 0; + } + + let mut context = Context::new(args); + if let Err(e) = maybe_authenticate(&mut context).await { + eprintln!("Error: {}", e); + return ErrorKind::SystemError as i32; + } + + let query_text = if context.args.command.is_empty() { + context.args.query.join(" ") + } else { + format!("{} {}", context.args.command, context.args.query.join(" ")) + }; + + // ── Headless mode: query provided on the command line ──────────────────── + if !query_text.is_empty() { + return match query(&mut context, query_text).await { + Ok(()) => 0, + Err(e) => exit_code_for(&e), + }; + } + + let is_tty = std::io::stdout().is_terminal() && std::io::stdin().is_terminal(); + context.is_interactive = is_tty; + + // ── Non-interactive (pipe/redirect): fall through to headless behaviour ── + if !is_tty { + use std::io::BufRead; + let stdin = std::io::stdin(); + let mut buffer = String::new(); + let mut worst: i32 = 0; + + for line in stdin.lock().lines() { + let line = match line { + Ok(l) => l, + Err(e) => { eprintln!("Error reading stdin: {}", e); return ErrorKind::SystemError as i32; } + }; + buffer.push_str(&line); + + if buffer.trim() == "quit" || buffer.trim() == "exit" { + buffer.clear(); + break; + } + + buffer.push('\n'); + + let queries = query::try_split_queries(&buffer).unwrap_or_default(); + if !queries.is_empty() { + for q in queries { + let dotcheck = q.trim().trim_end_matches(';').trim(); + if dot_command(&mut context, dotcheck) { + // client-side setting applied; nothing to send to server + } else { + worst = worst.max(run_query(&mut context, q).await); + } + } + buffer.clear(); + } + } + + // Try remaining buffer with implicit semicolon (EOF with incomplete query) + if !buffer.trim().is_empty() { + let text = format!("{};", buffer.trim()); + if let Some(queries) = query::try_split_queries(&text) { + for q in queries { + let dotcheck = q.trim().trim_end_matches(';').trim(); + if dot_command(&mut context, dotcheck) { + // client-side setting applied + } else { + worst = worst.max(run_query(&mut context, q).await); + } + } + } + } + + return worst; + } + + // ── Interactive TUI mode ───────────────────────────────────────────────── + let schema_cache = Arc::new(SchemaCache::new(context.args.completion_cache_ttl)); + let usage_tracker = Arc::new(UsageTracker::new(10)); + context.usage_tracker = Some(usage_tracker); + + let app = TuiApp::new(context, schema_cache); + match app.run().await { + Ok(had_error) => if had_error { 1 } else { 0 }, + Err(e) => { eprintln!("Error: {}", e); ErrorKind::SystemError as i32 } + } +} + +/// Run a single query and return its exit code (0, 1, or 2). +pub async fn run_query(context: &mut Context, q: String) -> i32 { + match query(context, q).await { + Ok(()) => 0, + Err(e) => exit_code_for(&e), + } +} + +/// Map a query error to an exit code. +pub fn exit_code_for(e: &Box) -> i32 { + if let Some(qf) = e.downcast_ref::() { + qf.0 as i32 + } else { + eprintln!("Error: {}", e); + ErrorKind::SystemError as i32 + } +} + +/// Returns the gumdrop-generated options block as a null-terminated C string. +/// The caller is responsible for freeing the pointer with `free()`. +#[no_mangle] +pub extern "C" fn fb_cli_usage_string() -> *mut c_char { + std::ffi::CString::new(args::Args::usage()) + .unwrap_or_default() + .into_raw() +} + +/// C-callable entry point. `argc`/`argv` mirror the process argv so the caller +/// can inject CLI flags (e.g. `["fb", "--core"]`). +#[no_mangle] +pub extern "C" fn fb_cli_main(argc: c_int, argv: *const *const c_char) -> c_int { + let args: Vec = (0..argc as usize) + .map(|i| unsafe { CStr::from_ptr(*argv.add(i)).to_string_lossy().into_owned() }) + .collect(); + + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("tokio runtime") + .block_on(run(args)) as c_int +} diff --git a/src/main.rs b/src/main.rs index 45df4b4..543bac6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,167 +1,7 @@ -use rustyline::{config::Configurer, error::ReadlineError, Cmd, DefaultEditor, EventHandler, KeyCode, KeyEvent, Modifiers}; -use std::io::IsTerminal; - -mod args; -mod auth; -mod context; -mod meta_commands; -mod query; -mod utils; - -use args::get_args; -use auth::maybe_authenticate; -use context::Context; -use meta_commands::handle_meta_command; -use query::{query, try_split_queries}; -use utils::history_path; - -pub const CLI_VERSION: &str = env!("CARGO_PKG_VERSION"); -pub const USER_AGENT: &str = concat!("fdb-cli/", env!("CARGO_PKG_VERSION")); -pub const FIREBOLT_PROTOCOL_VERSION: &str = "2.3"; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let args = get_args()?; - - if args.version { - println!("fb-cli version {}", CLI_VERSION); - return Ok(()); - } - - let mut context = Context::new(args); - maybe_authenticate(&mut context).await?; - - let query_text = if context.args.command.is_empty() { - context.args.query.join(" ") - } else { - format!("{} {}", context.args.command, context.args.query.join(" ")).to_string() - }; - - if !query_text.is_empty() { - query(&mut context, query_text).await?; - return Ok(()); - } - - let is_tty = std::io::stdout().is_terminal() && std::io::stdin().is_terminal(); - - let mut rl = DefaultEditor::new()?; - let history_path = history_path()?; - rl.set_max_history_size(10_000)?; - if rl.load_history(&history_path).is_err() { - if is_tty { - eprintln!("No previous history"); - } - } else if context.args.verbose { - eprintln!("Loaded history from {:?} and set max_history_size = 10'000", history_path) - } - - rl.bind_sequence(KeyEvent(KeyCode::Char('o'), Modifiers::CTRL), EventHandler::Simple(Cmd::Newline)); - - if is_tty { - eprintln!("Press Ctrl+D to exit."); - } - let mut buffer: String = String::new(); - let mut has_error = false; - loop { - let prompt = if !is_tty { - // No prompt when stdout is not a terminal (e.g., piped) - "" - } else if !buffer.trim_start().is_empty() { - // Continuation prompt (PROMPT2) - if let Some(custom_prompt) = &context.prompt2 { - custom_prompt.as_str() - } else { - "~> " - } - } else if context.args.extra.iter().any(|arg| arg.starts_with("transaction_id=")) { - // Transaction prompt (PROMPT3) - if let Some(custom_prompt) = &context.prompt3 { - custom_prompt.as_str() - } else { - "*> " - } - } else { - // Normal prompt (PROMPT1) - if let Some(custom_prompt) = &context.prompt1 { - custom_prompt.as_str() - } else { - "=> " - } - }; - let readline = rl.readline(prompt); - - match readline { - Ok(line) => { - buffer += line.as_str(); - - if buffer.trim() == "quit" || buffer.trim() == "exit" { - break; - } - - buffer += "\n"; - if !line.is_empty() { - // Check if this is a meta-command (backslash command) - if line.trim().starts_with('\\') { - if let Err(e) = handle_meta_command(&mut context, line.trim()) { - eprintln!("Error processing meta-command: {}", e); - } - buffer.clear(); - continue; - } - - let queries = try_split_queries(&buffer).unwrap_or_default(); - - if !queries.is_empty() { - rl.add_history_entry(buffer.trim())?; - rl.append_history(&history_path)?; - - for q in queries { - if query(&mut context, q).await.is_err() { - has_error = true; - } - } - - buffer.clear(); - } - } - } - Err(ReadlineError::Interrupted) => { - eprintln!("^C"); - buffer.clear(); - } - Err(ReadlineError::Eof) => { - if !buffer.trim().is_empty() { - buffer += ";"; - match try_split_queries(&buffer) { - None => {} - Some(queries) => { - for q in queries { - rl.add_history_entry(q.trim())?; - rl.append_history(&history_path)?; - if query(&mut context, q).await.is_err() { - has_error = true; - } - } - } - } - } - break; - } - Err(err) => { - eprintln!("Error: {:?}", err); - has_error = true; - break; - } - } - } - - if context.args.verbose { - eprintln!("Saved history to {:?}", history_path) - } - - if has_error { - Err("One or more queries failed".into()) - } else { - Ok(()) - } +fn main() { + let args: Vec = std::env::args() + .map(|a| std::ffi::CString::new(a).unwrap()) + .collect(); + let ptrs: Vec<*const std::os::raw::c_char> = args.iter().map(|a| a.as_ptr()).collect(); + std::process::exit(fb::fb_cli_main(ptrs.len() as _, ptrs.as_ptr())); } diff --git a/src/meta_commands.rs b/src/meta_commands.rs index d4f604c..dba0904 100644 --- a/src/meta_commands.rs +++ b/src/meta_commands.rs @@ -1,40 +1,40 @@ use crate::context::Context; -use regex::Regex; use once_cell::sync::Lazy; +use regex::Regex; -// Handle meta-commands (backslash commands) +// Handle meta-commands (slash commands) pub fn handle_meta_command(context: &mut Context, command: &str) -> Result> { - // Handle \set PROMPT1 command + // Handle /set PROMPT1 command if let Some(prompt) = parse_set_prompt(command, "PROMPT1") { context.set_prompt1(prompt); return Ok(true); } - // Handle \set PROMPT2 command + // Handle /set PROMPT2 command if let Some(prompt) = parse_set_prompt(command, "PROMPT2") { context.set_prompt2(prompt); return Ok(true); } - // Handle \set PROMPT3 command + // Handle /set PROMPT3 command if let Some(prompt) = parse_set_prompt(command, "PROMPT3") { context.set_prompt3(prompt); return Ok(true); } - // Handle \unset PROMPT1 command + // Handle /unset PROMPT1 command if parse_unset_prompt(command, "PROMPT1") { context.prompt1 = None; return Ok(true); } - // Handle \unset PROMPT2 command + // Handle /unset PROMPT2 command if parse_unset_prompt(command, "PROMPT2") { context.prompt2 = None; return Ok(true); } - // Handle \unset PROMPT3 command + // Handle /unset PROMPT3 command if parse_unset_prompt(command, "PROMPT3") { context.prompt3 = None; return Ok(true); @@ -43,11 +43,9 @@ pub fn handle_meta_command(context: &mut Context, command: &str) -> Result Option { - static SET_PROMPT_RE: Lazy = Lazy::new(|| { - Regex::new(r#"(?i)^\s*\\set\s+(\w+)\s+(?:'([^']*)'|"([^"]*)"|(\S+))\s*$"#).unwrap() - }); + static SET_PROMPT_RE: Lazy = Lazy::new(|| Regex::new(r#"(?i)^\s*/set\s+(\w+)\s+(?:'([^']*)'|"([^"]*)"|(\S+))\s*$"#).unwrap()); if let Some(captures) = SET_PROMPT_RE.captures(command) { // Check if the prompt type matches @@ -68,11 +66,9 @@ fn parse_set_prompt(command: &str, prompt_type: &str) -> Option { None } -// Generic function to parse \unset PROMPT command +// Generic function to parse /unset PROMPT command fn parse_unset_prompt(command: &str, prompt_type: &str) -> bool { - static UNSET_PROMPT_RE: Lazy = Lazy::new(|| { - Regex::new(r#"(?i)^\s*\\unset\s+(\w+)\s*$"#).unwrap() - }); + static UNSET_PROMPT_RE: Lazy = Lazy::new(|| Regex::new(r#"(?i)^\s*/unset\s+(\w+)\s*$"#).unwrap()); if let Some(captures) = UNSET_PROMPT_RE.captures(command) { if let Some(cmd_prompt_type) = captures.get(1) { @@ -92,8 +88,8 @@ mod tests { fn test_set_prompt1_single_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - - let command = r#"\set PROMPT1 'custom_prompt> '"#; + + let command = r#"/set PROMPT1 'custom_prompt> '"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt1, Some("custom_prompt> ".to_string())); @@ -103,8 +99,8 @@ mod tests { fn test_set_prompt1_double_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - - let command = r#"\set PROMPT1 "custom_prompt> ""#; + + let command = r#"/set PROMPT1 "custom_prompt> ""#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt1, Some("custom_prompt> ".to_string())); @@ -114,8 +110,8 @@ mod tests { fn test_set_prompt1_no_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - - let command = r#"\set PROMPT1 custom_prompt>"#; + + let command = r#"/set PROMPT1 custom_prompt>"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt1, Some("custom_prompt>".to_string())); @@ -125,8 +121,8 @@ mod tests { fn test_set_prompt2_single_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - - let command = r#"\set PROMPT2 'custom_prompt> '"#; + + let command = r#"/set PROMPT2 'custom_prompt> '"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt2, Some("custom_prompt> ".to_string())); @@ -136,8 +132,8 @@ mod tests { fn test_set_prompt2_double_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - - let command = r#"\set PROMPT2 "custom_prompt> ""#; + + let command = r#"/set PROMPT2 "custom_prompt> ""#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt2, Some("custom_prompt> ".to_string())); @@ -147,8 +143,8 @@ mod tests { fn test_set_prompt2_no_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - - let command = r#"\set PROMPT2 custom_prompt>"#; + + let command = r#"/set PROMPT2 custom_prompt>"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt2, Some("custom_prompt>".to_string())); @@ -158,8 +154,8 @@ mod tests { fn test_set_prompt3_single_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - - let command = r#"\set PROMPT3 'custom_prompt> '"#; + + let command = r#"/set PROMPT3 'custom_prompt> '"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt3, Some("custom_prompt> ".to_string())); @@ -169,8 +165,8 @@ mod tests { fn test_set_prompt3_double_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - - let command = r#"\set PROMPT3 "custom_prompt> ""#; + + let command = r#"/set PROMPT3 "custom_prompt> ""#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt3, Some("custom_prompt> ".to_string())); @@ -180,8 +176,8 @@ mod tests { fn test_set_prompt3_no_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - - let command = r#"\set PROMPT3 custom_prompt>"#; + + let command = r#"/set PROMPT3 custom_prompt>"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt3, Some("custom_prompt>".to_string())); @@ -191,13 +187,13 @@ mod tests { fn test_unset_prompt1() { let args = get_args().unwrap(); let mut context = Context::new(args); - + // First set a prompt context.set_prompt1("test> ".to_string()); assert_eq!(context.prompt1, Some("test> ".to_string())); - + // Then unset it - let command = r#"\unset PROMPT1"#; + let command = r#"/unset PROMPT1"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt1, None); @@ -207,13 +203,13 @@ mod tests { fn test_unset_prompt2() { let args = get_args().unwrap(); let mut context = Context::new(args); - + // First set a prompt context.set_prompt2("test> ".to_string()); assert_eq!(context.prompt2, Some("test> ".to_string())); - + // Then unset it - let command = r#"\unset PROMPT2"#; + let command = r#"/unset PROMPT2"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt2, None); @@ -223,13 +219,13 @@ mod tests { fn test_unset_prompt3() { let args = get_args().unwrap(); let mut context = Context::new(args); - + // First set a prompt context.set_prompt3("test> ".to_string()); assert_eq!(context.prompt3, Some("test> ".to_string())); - + // Then unset it - let command = r#"\unset PROMPT3"#; + let command = r#"/unset PROMPT3"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt3, None); @@ -239,13 +235,13 @@ mod tests { fn test_invalid_commands() { let args = get_args().unwrap(); let mut context = Context::new(args); - + // Invalid commands should return false let command = r#"\invalid command"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(!result); - - let command = r#"\set INVALID value"#; + + let command = r#"/set INVALID value"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(!result); } @@ -254,9 +250,9 @@ mod tests { fn test_whitespace_handling() { let args = get_args().unwrap(); let mut context = Context::new(args); - + // Test with various whitespace - let command = r#" \set PROMPT1 'test>' "#; + let command = r#" /set PROMPT1 'test>' "#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); assert_eq!(context.prompt1, Some("test>".to_string())); @@ -266,25 +262,25 @@ mod tests { fn test_prompt_independence() { let args = get_args().unwrap(); let mut context = Context::new(args); - + // Set all three prompts to different values - let command1 = r#"\set PROMPT1 'prompt1> '"#; - let command2 = r#"\set PROMPT2 'prompt2> '"#; - let command3 = r#"\set PROMPT3 'prompt3> '"#; - + let command1 = r#"/set PROMPT1 'prompt1> '"#; + let command2 = r#"/set PROMPT2 'prompt2> '"#; + let command3 = r#"/set PROMPT3 'prompt3> '"#; + handle_meta_command(&mut context, command1).unwrap(); handle_meta_command(&mut context, command2).unwrap(); handle_meta_command(&mut context, command3).unwrap(); - + // Verify all prompts are set independently assert_eq!(context.prompt1, Some("prompt1> ".to_string())); assert_eq!(context.prompt2, Some("prompt2> ".to_string())); assert_eq!(context.prompt3, Some("prompt3> ".to_string())); - + // Unset only PROMPT2 - let unset_command = r#"\unset PROMPT2"#; + let unset_command = r#"/unset PROMPT2"#; handle_meta_command(&mut context, unset_command).unwrap(); - + // Verify only PROMPT2 was unset assert_eq!(context.prompt1, Some("prompt1> ".to_string())); assert_eq!(context.prompt2, None); @@ -295,16 +291,16 @@ mod tests { fn test_case_insensitive_prompt_types() { let args = get_args().unwrap(); let mut context = Context::new(args); - + // Test case insensitive prompt type matching - let command1 = r#"\set prompt1 'test1> '"#; - let command2 = r#"\set Prompt2 'test2> '"#; - let command3 = r#"\set PROMPT3 'test3> '"#; - + let command1 = r#"/set prompt1 'test1> '"#; + let command2 = r#"/set Prompt2 'test2> '"#; + let command3 = r#"/set PROMPT3 'test3> '"#; + handle_meta_command(&mut context, command1).unwrap(); handle_meta_command(&mut context, command2).unwrap(); handle_meta_command(&mut context, command3).unwrap(); - + // Verify all prompts are set correctly regardless of case assert_eq!(context.prompt1, Some("test1> ".to_string())); assert_eq!(context.prompt2, Some("test2> ".to_string())); diff --git a/src/query.rs b/src/query.rs index d13043b..eb2628d 100644 --- a/src/query.rs +++ b/src/query.rs @@ -2,16 +2,293 @@ use once_cell::sync::Lazy; use pest::Parser; use pest_derive::Parser; use regex::Regex; +use std::fmt; use std::time::Instant; use tokio::{select, signal, task}; use tokio_util::sync::CancellationToken; +/// Reassembles arbitrarily-chunked byte streams into valid UTF-8 strings, +/// carrying incomplete multi-byte sequences across chunk boundaries. +struct ChunkDecoder { + carry: Vec, + total_decoded: usize, +} + +impl ChunkDecoder { + fn new() -> Self { + Self { + carry: Vec::new(), + total_decoded: 0, + } + } + + /// Feed a chunk of bytes. On success returns the decoded UTF-8 string. + /// On failure returns an error message describing the invalid bytes. + fn feed(&mut self, chunk: &[u8]) -> Result { + let data = if self.carry.is_empty() { + chunk.to_vec() + } else { + let mut combined = std::mem::take(&mut self.carry); + combined.extend_from_slice(chunk); + combined + }; + match std::str::from_utf8(&data) { + Ok(s) => { + self.total_decoded += data.len(); + Ok(s.to_string()) + } + Err(e) => { + let valid_end = e.valid_up_to(); + let remainder = &data[valid_end..]; + if e.error_len().is_none() && remainder.len() <= 3 { + let valid = if valid_end > 0 { + std::str::from_utf8(&data[..valid_end]).unwrap().to_string() + } else { + String::new() + }; + self.total_decoded += valid_end; + self.carry = remainder.to_vec(); + Ok(valid) + } else { + let bad_len = e.error_len().unwrap_or(1); + let window_start = valid_end.saturating_sub(32); + let window_end = (valid_end + bad_len + 32).min(data.len()); + let hex_context: Vec = data[window_start..window_end] + .iter() + .enumerate() + .map(|(i, b)| { + let abs = i + window_start; + if abs >= valid_end && abs < valid_end + bad_len { + format!("[{:02x}]", b) + } else { + format!("{:02x}", b) + } + }) + .collect(); + let valid_prefix = String::from_utf8_lossy(&data[window_start..valid_end]); + Err(format!( + "Invalid UTF-8 in response at byte {} ({} bytes already decoded). \ + Hex near error: {} | Text near error: \"{}\u{fffd}...\"", + self.total_decoded + valid_end, + self.total_decoded, + hex_context.join(" "), + valid_prefix + .chars() + .rev() + .take(64) + .collect::() + .chars() + .rev() + .collect::(), + )) + } + } + } + } + + /// Call when the stream has ended. Returns an error if there are leftover + /// carry bytes (truncated multi-byte sequence). + fn finish(self) -> Result<(), String> { + if self.carry.is_empty() { + Ok(()) + } else { + let hex: Vec = self.carry.iter().map(|b| format!("{:02x}", b)).collect(); + Err(format!( + "Invalid UTF-8 at end of response: stream ended with {} trailing bytes (hex: {})", + self.carry.len(), + hex.join(" "), + )) + } + } +} + +/// Extract a human-readable error message from a raw server response body. +/// +/// Tries to parse as JSON and look for common error fields; falls back to the +/// trimmed raw text. Accepts multi-line bodies (pretty-printed JSON objects). +fn readable_error(body: &str) -> String { + let trimmed = body.trim(); + if let Ok(v) = serde_json::from_str::(trimmed) { + // Top-level string fields. + for field in &["error", "message", "description", "detail"] { + if let Some(s) = v.get(field).and_then(|v| v.as_str()) { + return s.to_string(); + } + } + // `errors` array (e.g. {"errors":[{"description":"..."}]}). + if let Some(arr) = v.get("errors").and_then(|v| v.as_array()) { + let msgs: Vec<&str> = arr + .iter() + .filter_map(|e| e.get("description").and_then(|d| d.as_str())) + .collect(); + if !msgs.is_empty() { + return msgs.join("; "); + } + } + // Valid JSON but no recognised error field — return compact form. + return serde_json::to_string(&v).unwrap_or_else(|_| trimmed.to_string()); + } + trimmed.to_string() +} + +/// Distinguishes between user-caused and system/infrastructure failures. +/// Values double as process exit codes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ErrorKind { + /// Bad SQL, permission denied, query rejected by the engine (HTTP 200 error body or HTTP 400). + QueryError = 1, + /// Connection refused, auth failure, HTTP 4xx/5xx other than 400, response decode error. + SystemError = 2, +} + +/// Error returned by [`query()`] that carries an [`ErrorKind`]. +/// The error message has already been printed to stderr before this is returned. +#[derive(Debug)] +pub struct QueryFailed(pub ErrorKind); + +impl fmt::Display for QueryFailed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.0 { + ErrorKind::QueryError => write!(f, "Query failed"), + ErrorKind::SystemError => write!(f, "System error"), + } + } +} + +impl std::error::Error for QueryFailed {} + use crate::args::normalize_extras; use crate::auth::authenticate_service_account; use crate::context::Context; +use crate::table_renderer; +use crate::transport; use crate::utils::spin; -use crate::FIREBOLT_PROTOCOL_VERSION; -use crate::USER_AGENT; + +// ── Output helpers ──────────────────────────────────────────────────────────── +// These macros route output through the TUI channel when running in TUI mode, +// otherwise fall back to println!/eprintln! as before. + +macro_rules! out { + ($ctx:expr, $($arg:tt)*) => { + $ctx.emit(format!($($arg)*)) + }; +} + +macro_rules! out_err { + ($ctx:expr, $($arg:tt)*) => { + $ctx.emit_err(format!($($arg)*)) + }; +} + +// Format bytes with appropriate unit (B, KB, MB, GB, TB) +fn format_bytes(bytes: u64) -> String { + const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; + + if bytes == 0 { + return "0 B".to_string(); + } + + let bytes_f64 = bytes as f64; + let unit_index = (bytes_f64.log2() / 10.0).floor() as usize; + let unit_index = unit_index.min(UNITS.len() - 1); + + let value = bytes_f64 / (1024_f64.powi(unit_index as i32)); + + if value >= 100.0 { + format!("{:.0} {}", value, UNITS[unit_index]) + } else if value >= 10.0 { + format!("{:.1} {}", value, UNITS[unit_index]) + } else { + format!("{:.2} {}", value, UNITS[unit_index]) + } +} + +// Format number with thousand separators +fn format_number(n: u64) -> String { + let s = n.to_string(); + let mut result = String::new(); + let mut count = 0; + + for c in s.chars().rev() { + if count > 0 && count % 3 == 0 { + result.push(','); + } + result.push(c); + count += 1; + } + + result.chars().rev().collect() +} + +const INTERACTIVE_MAX_ROWS: usize = 10_000; +const INTERACTIVE_MAX_BYTES: usize = 1_048_576; // 1 MB + + +/// Handle client-side dot commands: `.format = value`, `.completion = on|off`, etc. +/// Returns `true` if the input was a dot command (even if the key was unknown), +/// `false` if the line does not start with `.`. +pub fn dot_command(context: &mut Context, line: &str) -> bool { + static DOT_RE: Lazy = + Lazy::new(|| Regex::new(r"^\.(\w+)\s*(?:=\s*(.*?))?\s*;?\s*$").unwrap()); + + let line = line.trim(); + if !line.starts_with('.') { + return false; + } + + let caps = match DOT_RE.captures(line) { + Some(c) => c, + None => { + out_err!(context, "Error: invalid dot command: {}", line); + return true; + } + }; + + let key = caps.get(1).map_or("", |m| m.as_str()); + let has_eq = line.contains('='); + let value = caps.get(2).map_or("", |m| m.as_str().trim()); + + match key { + "format" => { + if !has_eq { + out_err!(context, "format = {}", context.args.format); + } else if value.is_empty() { + context.args.format = "client:auto".to_string(); + context.update_url(); + } else if !value.starts_with("client:") { + out_err!( + context, + "Error: .format only accepts client-side formats: client:auto, client:vertical, client:horizontal. \ + Use --format or 'set output_format=;' for server-side formats." + ); + } else { + context.args.format = value.to_string(); + context.update_url(); + } + } + "completion" => { + if !has_eq { + let status = if context.args.no_completion { "off" } else { "on" }; + out_err!(context, "completion = {}", status); + } else if value.is_empty() { + context.args.no_completion = false; // reset to default (enabled) + } else { + let val_lower = value.to_lowercase(); + if val_lower == "on" || val_lower == "true" || val_lower == "1" { + context.args.no_completion = false; + } else if val_lower == "off" || val_lower == "false" || val_lower == "0" { + context.args.no_completion = true; + } else { + out_err!(context, "Error: invalid value for .completion: '{}'. Use 'on' or 'off'.", value); + } + } + } + _ => { + out_err!(context, "Error: unknown client setting '.{}'. Available: .format, .completion", key); + } + } + true +} // Set parameters via query pub fn set_args(context: &mut Context, query: &str) -> Result> { @@ -30,6 +307,10 @@ pub fn set_args(context: &mut Context, query: &str) -> Result = vec![]; buf.push(format!("{key}={value}")); buf = normalize_extras(buf, true)?; @@ -37,8 +318,8 @@ pub fn set_args(context: &mut Context, query: &str) -> Result Result Result Result<(), Box> { + for pair in header_value.split(',') { + let pair = pair.trim(); + if pair.is_empty() { + continue; + } + set_args(context, &format!("set {}", pair))?; + } + Ok(()) +} + +/// Apply a `Firebolt-Remove-Parameters` or `Firebolt-Reset-Session` header. +/// +/// `keys` is a comma-separated list of parameter names to remove from +/// `context.args.extra`, e.g. `"transaction_id,transaction_sequence_id"`. +fn remove_parameters(context: &mut Context, keys: &str) { + for key in keys.split(',') { + let key = key.trim(); + if key.is_empty() { + continue; + } + let prefix = format!("{}=", key); + context.args.extra.retain(|e| !e.starts_with(&prefix)); + } + context.update_url(); +} + +// Execute a query silently and return the response body (for internal use like schema queries) +pub async fn query_silent(context: &mut Context, query_text: &str) -> Result> { + let auth = if let Some(sa_token) = &context.sa_token { + Some(format!("Bearer {}", sa_token.token)) + } else if !context.args.jwt.is_empty() { + Some(format!("Bearer {}", context.args.jwt)) + } else { + None + }; + let unix_socket = (!context.args.unix_socket.is_empty()).then_some(context.args.unix_socket.as_str()); + let response = transport::post(&context.url, unix_socket, query_text.to_string(), auth, true).await?; + Ok(response.text().await?) +} + +/// Send `SELECT 1;` with the current context URL to validate that all query +/// parameters are accepted by the server. Returns an error message on HTTP +/// 4xx/5xx or connection failure. +pub async fn validate_setting(context: &mut Context) -> Result<(), String> { + let auth = if let Some(sa_token) = &context.sa_token { + Some(format!("Bearer {}", sa_token.token)) + } else if !context.args.jwt.is_empty() { + Some(format!("Bearer {}", context.args.jwt)) + } else { + None + }; + let unix_socket = (!context.args.unix_socket.is_empty()).then_some(context.args.unix_socket.as_str()); + let response = transport::post(&context.url, unix_socket, "SELECT 1;".to_string(), auth, true) + .await + .map_err(|e| e.to_string())?; + if !response.status().is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(readable_error(&body)); + } + Ok(()) +} + +/// Render a result table to a String using the display mode from context. +/// Render a result table as a plain-text String using the same renderer as the +/// TUI, so headless output matches the interactive REPL visually. +fn render_table_plain( + context: &Context, + columns: &[table_renderer::ResultColumn], + rows: &[Vec], + terminal_width: u16, + max_cell_length: usize, +) -> String { + let tw = terminal_width.saturating_sub(1); + let lines = if context.args.is_vertical_display() { + table_renderer::render_vertical_table_to_tui_lines(columns, rows, tw, max_cell_length) + } else if context.args.is_horizontal_display() { + table_renderer::render_horizontal_forced_tui_lines(columns, rows, tw, max_cell_length) + } else { + table_renderer::render_table_to_tui_lines(columns, rows, tw, max_cell_length) + }; + lines + .into_iter() + .map(|line| line.0.into_iter().map(|s| s.text).collect::()) + .collect::>() + .join("\n") +} + +/// In TUI mode, emit a result table as pre-styled TuiLines (no ANSI round-trip). +fn emit_table_tui( + context: &Context, + columns: &[table_renderer::ResultColumn], + rows: &[Vec], + terminal_width: u16, + max_cell_length: usize, +) { + // Leave a 1-column margin so the rightmost border isn't clipped by ratatui. + let tw = terminal_width.saturating_sub(1); + let lines = if context.args.is_vertical_display() { + table_renderer::render_vertical_table_to_tui_lines(columns, rows, tw, max_cell_length) + } else if context.args.is_horizontal_display() { + table_renderer::render_horizontal_forced_tui_lines(columns, rows, tw, max_cell_length) + } else { + table_renderer::render_table_to_tui_lines(columns, rows, tw, max_cell_length) + }; + context.emit_styled_lines(lines); +} + +/// Extract a human-readable scan-statistics string from the FINISH_SUCCESSFULLY payload. +fn compute_stats(statistics: &Option) -> Option { + if statistics.is_none() { + return None; + } + statistics.as_ref().and_then(|stats| { + stats.as_object().map(|obj| { + let scanned_cache = obj.get("scanned_bytes_cache").and_then(|v| v.as_u64()).unwrap_or(0); + let scanned_storage = obj.get("scanned_bytes_storage").and_then(|v| v.as_u64()).unwrap_or(0); + let rows_read = obj.get("rows_read").and_then(|v| v.as_u64()).unwrap_or(0); + let total_scanned = scanned_cache + scanned_storage; + if rows_read > 0 || total_scanned > 0 { + Some(format!( + "Scanned: {} rows, {} ({} local, {} remote)", + format_number(rows_read), + format_bytes(total_scanned), + format_bytes(scanned_cache), + format_bytes(scanned_storage), + )) + } else { + None + } + }).flatten() + }) +} + // Send query and print result. pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box> { // Handle set/unset commands if set_args(context, &query_text)? { - if !context.args.concise && !context.args.hide_pii { - eprintln!("URL: {}", context.url); - } - return Ok(()); } if unset_args(context, &query_text)? { - if !context.args.concise && !context.args.hide_pii { - eprintln!("URL: {}", context.url); - } - return Ok(()); } @@ -93,77 +506,76 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< } if context.args.verbose { - eprintln!("URL: {}", context.url); - eprintln!("QUERY: {}", query_text); + out_err!(context, "URL: {}", context.url); + out_err!(context, "QUERY: {}", query_text); } let start = Instant::now(); - let mut request = reqwest::Client::builder() - .http2_keep_alive_timeout(std::time::Duration::from_secs(3600)) - .http2_keep_alive_interval(Some(std::time::Duration::from_secs(60))) - .http2_keep_alive_while_idle(false) - .tcp_keepalive(Some(std::time::Duration::from_secs(60))) - .build()? - .post(context.url.clone()) - .header("user-agent", USER_AGENT) - .header("Firebolt-Protocol-Version", FIREBOLT_PROTOCOL_VERSION) - .body(query_text); - - if let Some(sa_token) = &context.sa_token { - request = request.header("authorization", format!("Bearer {}", sa_token.token)); - } - - if !context.args.jwt.is_empty() { - request = request.header("authorization", format!("Bearer {}", context.args.jwt)); - } + // Clone query_text for tracking later + let query_text_for_tracking = query_text.clone(); - let async_resp = request.send(); - - let finish_token = CancellationToken::new(); - let maybe_spin = if context.args.no_spinner || context.args.concise { + let auth = if let Some(sa_token) = &context.sa_token { + Some(format!("Bearer {}", sa_token.token)) + } else if !context.args.jwt.is_empty() { + Some(format!("Bearer {}", context.args.jwt)) + } else { None + }; + let unix_socket = (!context.args.unix_socket.is_empty()).then_some(context.args.unix_socket.as_str()); + let url = context.url.clone(); + let async_resp = transport::post(&url, unix_socket, query_text, auth, false); + + // Build a cancel future: prefer the TUI's CancellationToken, fall back to SIGINT. + let cancel = context.query_cancel.clone(); + + // Show spinner in non-interactive mode only for client-side formats. + let spin_token = CancellationToken::new(); + let mut maybe_spin = if !context.is_tui() && context.args.should_render_table() { + let t = spin_token.clone(); + Some(task::spawn(async move { spin(t).await; })) } else { - let token_clone = finish_token.clone(); - Some(task::spawn(async { - spin(token_clone).await; - })) + None }; - let mut query_failed = false; + let mut error_kind: Option = None; select! { - _ = signal::ctrl_c() => { - finish_token.cancel(); - if let Some(spin) = maybe_spin { - spin.await?; - } - if !context.args.concise { - eprintln!("^C"); + _ = async { + if let Some(token) = cancel { + token.cancelled().await; + } else { + let _ = signal::ctrl_c().await; } - query_failed = true; + } => { + out_err!(context, "^C"); + error_kind = Some(ErrorKind::SystemError); } response = async_resp => { + // Erase the spinner before producing any output, so the backspace + // sequence lands on the spinner character and not on the table. + spin_token.cancel(); + if let Some(s) = maybe_spin.take() { let _ = s.await; } + let elapsed = start.elapsed(); - finish_token.cancel(); - if let Some(spin) = maybe_spin { - spin.await?; - } let mut maybe_request_id: Option = None; match response { - Ok(resp) => { + Ok(mut resp) => { let mut updated_url = false; for (header, value) in resp.headers() { - if header == "firebolt-remove-parameters" { - unset_args(context, format!("unset {}", value.to_str()?).as_str())?; + if header == "firebolt-update-parameters" { + apply_update_parameters(context, value.to_str()?)?; + updated_url = true; + } else if header == "firebolt-remove-parameters" { + remove_parameters(context, value.to_str()?); updated_url = true; - } else if header == "firebolt-update-parameters" { - set_args(context, format!("set {}", value.to_str()?).as_str())?; + } else if header == "firebolt-reset-session" { + // End of transaction: clear transaction_id and transaction_sequence_id. + remove_parameters(context, "transaction_id,transaction_sequence_id"); updated_url = true; } else if header == "X-REQUEST-ID" { maybe_request_id = value.to_str().map_or(None, |l| Some(String::from(l))); - updated_url = true; } else if header == "firebolt-update-endpoint" { let header_str = value.to_str()?; // Split the header at the '?' character @@ -188,44 +600,272 @@ pub async fn query(context: &mut Context, query_text: String) -> Result<(), Box< updated_url = true; } } - if updated_url && !context.args.concise && !context.args.hide_pii { - eprintln!("URL: {}", context.url); + if updated_url { + if context.args.verbose && !context.args.hide_pii { + out_err!(context, "URL: {}", context.url); + } + // Propagate the new extras back to the TUI's own context + // (the task runs on a clone; without this the transaction + // badge and similar features would never see the changes). + if let Some(tx) = &context.tui_output_tx { + let _ = tx.send(crate::tui_msg::TuiMsg::ParamUpdate( + context.args.extra.clone(), + )); + } } let status = resp.status(); - let body = resp.text().await?; - // on stdout, on purpose - print!("{}", body); + if context.args.should_render_table() && context.is_interactive { + // ── Streaming path: emit display rows at the limit, + // keep collecting all rows for csvlens. ──────────── + use table_renderer::{ErrorDetail, JsonLineMessage, ParsedResult, ResultColumn}; + + let mut columns: Vec = Vec::new(); + let mut display_rows: Vec> = Vec::new(); + let mut all_rows: Vec> = Vec::new(); + let mut statistics: Option = None; + let mut errors: Option> = None; + let mut display_emitted = false; + let mut display_byte_count = 0usize; + + let terminal_width = terminal_size::terminal_size() + .map(|(terminal_size::Width(w), _)| w) + .unwrap_or(80); + + let mut line_buf = String::new(); + let mut stream_err: Option = None; + let mut chunk_decoder = ChunkDecoder::new(); + + 'stream: loop { + match resp.chunk().await { + Err(e) => { stream_err = Some(e.to_string()); break 'stream; } + Ok(None) => { + if let Err(e) = chunk_decoder.finish() { + stream_err = Some(e); + } + break 'stream; + } + Ok(Some(chunk)) => { + match chunk_decoder.feed(&chunk) { + Ok(s) => line_buf.push_str(&s), + Err(e) => { stream_err = Some(e); break 'stream; } + } + while let Some(nl) = line_buf.find('\n') { + let line = line_buf[..nl].trim().to_string(); + line_buf.drain(..nl + 1); + if line.is_empty() { continue; } + + match serde_json::from_str::(&line) { + Err(parse_err) => { + // The line isn't a JSONLines message — the server + // likely sent a multi-line JSON error object. + // Drain the rest of the response so we have the + // complete body, then surface a readable message. + let mut body = line.clone(); + body.push('\n'); + body.push_str(&line_buf); + 'drain: loop { + match resp.chunk().await { + Ok(Some(c)) => { + if let Ok(s) = std::str::from_utf8(&c) { + body.push_str(s); + } + } + _ => break 'drain, + } + } + let msg = readable_error(&body); + stream_err = Some(if context.args.verbose { + format!("{} (parse error: {})", msg, parse_err) + } else { + msg + }); + break 'stream; + } + Ok(msg) => match msg { + JsonLineMessage::Start { result_columns, .. } => { + columns = result_columns; + } + JsonLineMessage::Data { data } => { + for row in data { + if !display_emitted { + let row_bytes: usize = row.iter().map(|v| v.to_string().len()).sum(); + let over_rows = display_rows.len() >= INTERACTIVE_MAX_ROWS; + let over_bytes = display_byte_count + row_bytes > INTERACTIVE_MAX_BYTES; + if over_rows || over_bytes { + let max_cell = if columns.len() == 1 { context.args.max_cell_length * 5 } else { context.args.max_cell_length }; + if context.is_tui() { + emit_table_tui(context, &columns, &display_rows, terminal_width, max_cell); + } else { + let rendered = render_table_plain(context, &columns, &display_rows, terminal_width, max_cell); + out!(context, "{}", rendered); + } + out_err!(context, "Showing first {} rows — collecting remainder for Ctrl+V / /view...", + format_number(display_rows.len() as u64)); + display_emitted = true; + } else { + display_byte_count += row_bytes; + display_rows.push(row.clone()); + } + } + all_rows.push(row); + } + // Send live row count to the TUI running pane. + if context.is_tui() { + if let Some(tx) = &context.tui_output_tx { + let _ = tx.send(crate::tui_msg::TuiMsg::Progress(all_rows.len() as u64)); + } + } + } + JsonLineMessage::FinishSuccessfully { statistics: s } => { + statistics = s; + } + JsonLineMessage::FinishWithErrors { errors: errs } => { + errors = Some(errs); + } + } + } + } + } + } + } + + if let Some(e) = stream_err { + out_err!(context, "Error: {}", e); + let kind = if status.as_u16() == 400 { ErrorKind::QueryError } else { ErrorKind::SystemError }; + error_kind = Some(kind); + } else if !status.is_success() && errors.is_none() && columns.is_empty() { + // Non-2xx with an empty or unparseable body — show the HTTP status. + out_err!(context, "Error: server returned HTTP {}", status.as_u16()); + let kind = if status.as_u16() == 400 { ErrorKind::QueryError } else { ErrorKind::SystemError }; + error_kind = Some(kind); + } else if let Some(errs) = errors { + for err in errs { + out_err!(context, "Error: {}", err.description); + } + error_kind = Some(ErrorKind::QueryError); + } else { + if !columns.is_empty() { + if !display_emitted { + // All rows fit within limits — emit now + let max_cell = if columns.len() == 1 { context.args.max_cell_length * 5 } else { context.args.max_cell_length }; + if context.is_tui() { + emit_table_tui(context, &columns, &all_rows, terminal_width, max_cell); + } else { + let rendered = render_table_plain(context, &columns, &all_rows, terminal_width, max_cell); + out!(context, "{}", rendered); + } + } else { + // Partial display was already emitted; show the final total + out_err!(context, "Showing {} of {} rows (press Ctrl+V or /view to see all).", + format_number(display_rows.len() as u64), + format_number(all_rows.len() as u64)); + } + } + // Always emit ParsedResult on success so the TUI can detect + // DDL statements (zero columns) and refresh the schema cache. + let parsed_result = ParsedResult { + columns: columns.clone(), + rows: all_rows, + statistics: statistics.clone(), + errors: None, + }; + context.emit_parsed_result(&parsed_result); + context.last_result = Some(parsed_result); + context.last_stats = compute_stats(&statistics); + } + + } else { + // ── Buffered path (non-interactive or server-rendered) ── + let body = resp.text().await.map_err(|e| -> Box { e })?; + + if context.args.should_render_table() { + match table_renderer::parse_jsonlines_compact(&body) { + Ok(parsed) => { + context.emit_parsed_result(&parsed); + context.last_result = Some(parsed.clone()); + if let Some(errors) = parsed.errors { + for error in errors { + out_err!(context, "Error: {}", error.description); + } + error_kind = Some(ErrorKind::QueryError); + } else if !parsed.columns.is_empty() { + let terminal_width = terminal_size::terminal_size() + .map(|(terminal_size::Width(w), _)| w) + .unwrap_or(80); + let max_cell_length = if parsed.columns.len() == 1 { + context.args.max_cell_length * 5 + } else { + context.args.max_cell_length + }; + if context.is_tui() { + emit_table_tui(context, &parsed.columns, &parsed.rows, terminal_width, max_cell_length); + } else { + let table_output = render_table_plain(context, &parsed.columns, &parsed.rows, terminal_width, max_cell_length); + out!(context, "{}", table_output); + } + context.last_stats = compute_stats(&parsed.statistics); + } + } + Err(e) => { + if context.args.verbose { + out_err!(context, "Failed to parse table format: {}", e); + } + context.emit_raw(&body); + } + } + } else { + context.emit_raw(&body); + } - if !status.is_success() { - query_failed = true; + if !status.is_success() { + let kind = if status.as_u16() == 400 { ErrorKind::QueryError } else { ErrorKind::SystemError }; + if error_kind != Some(ErrorKind::SystemError) { error_kind = Some(kind); } + } } } Err(error) => { - if context.args.verbose { - eprintln!("Failed to send the request: {:?}", error); - } else { - eprintln!("Failed to send the request: {}", error.to_string()); - } - query_failed = true; + out_err!(context, "Failed to send the request: {}", error); + error_kind = Some(ErrorKind::SystemError); }, }; - if !context.args.concise { - let elapsed = format!("{:?}", elapsed / 100000 * 100000); - eprintln!("Time: {elapsed}"); + let elapsed = format!("{:?}", elapsed / 100000 * 100000); + if context.args.should_render_table() && !context.is_tui() { + // Client-side format in headless: emit to stdout so stats + // follow the table (mirrors TUI output-pane behaviour). + out!(context, "Time: {elapsed}"); + if let Some(stats) = &context.last_stats { + out!(context, "{}", stats); + } if let Some(request_id) = maybe_request_id { - eprintln!("Request Id: {request_id}"); + out!(context, "Request Id: {request_id}"); } - eprintln!("") + out!(context, ""); + } else if context.is_tui() { + // TUI: send stats to output pane. + out_err!(context, "Time: {elapsed}"); + if let Some(stats) = &context.last_stats { + out_err!(context, "{}", stats); + } + context.emit_newline(); } } }; - if query_failed { - Err("Query failed".into()) + spin_token.cancel(); + if let Some(s) = maybe_spin { + let _ = s.await; + } + + if let Some(kind) = error_kind { + Err(Box::new(QueryFailed(kind))) } else { + // Track successful query for auto-completion prioritization + if let Some(usage_tracker) = &context.usage_tracker { + usage_tracker.track_query(&query_text_for_tracking); + } Ok(()) } } @@ -266,8 +906,6 @@ mod tests { let mut args = get_args().unwrap(); args.host = "localhost:8123".to_string(); args.database = "test_db".to_string(); - args.concise = true; // suppress output - let mut context = Context::new(args); let query_text = "select 42".to_string(); @@ -454,6 +1092,13 @@ mod tests { assert!(result); assert!(context.args.extra.iter().any(|e| e == "engine=default")); + // Test setting database updates both args.database and args.extra + let query = "set database = mydb"; + let result = set_args(&mut context, query).unwrap(); + assert!(result); + assert_eq!(context.args.database, "mydb"); + assert!(context.args.extra.iter().any(|e| e == "database=mydb")); + // Test with comments before SET command let query = "-- Setting a parameter\nset test = value"; let result = set_args(&mut context, query).unwrap(); @@ -479,7 +1124,7 @@ mod tests { let query = "unset format"; let result = unset_args(&mut context, query).unwrap(); assert!(result); - assert_eq!(context.args.format, "PSQL"); + assert_eq!(context.args.format, "client:auto"); // Test with comments before UNSET command context.args.extra.push("test=value".to_string()); @@ -726,6 +1371,36 @@ mod tests { assert!(try_split_queries(input).is_none()); } + #[test] + fn test_format_bytes() { + assert_eq!(format_bytes(0), "0 B"); + assert_eq!(format_bytes(1), "1.00 B"); + assert_eq!(format_bytes(100), "100 B"); + assert_eq!(format_bytes(1023), "1023 B"); + assert_eq!(format_bytes(1024), "1.00 KB"); + assert_eq!(format_bytes(1536), "1.50 KB"); + assert_eq!(format_bytes(10240), "10.0 KB"); + assert_eq!(format_bytes(102400), "100 KB"); + assert_eq!(format_bytes(1048576), "1.00 MB"); + assert_eq!(format_bytes(1572864), "1.50 MB"); + assert_eq!(format_bytes(10485760), "10.0 MB"); + assert_eq!(format_bytes(104857600), "100 MB"); + assert_eq!(format_bytes(1073741824), "1.00 GB"); + assert_eq!(format_bytes(1099511627776), "1.00 TB"); + } + + #[test] + fn test_format_number() { + assert_eq!(format_number(0), "0"); + assert_eq!(format_number(1), "1"); + assert_eq!(format_number(999), "999"); + assert_eq!(format_number(1000), "1,000"); + assert_eq!(format_number(1234), "1,234"); + assert_eq!(format_number(123456), "123,456"); + assert_eq!(format_number(1234567), "1,234,567"); + assert_eq!(format_number(1234567890), "1,234,567,890"); + } + #[test] fn test_empty_strings() { // Raw strings @@ -809,4 +1484,254 @@ mod tests { assert_eq!(queries.len(), 1); assert_eq!(queries[0], r#"SELECT "hello";"#); } + + // ── Transaction parameter helpers ───────────────────────────────────────── + + fn make_context() -> Context { + let args = get_args().unwrap(); + Context::new(args) + } + + #[test] + fn test_apply_update_parameters_single_pair() { + let mut ctx = make_context(); + assert!(!ctx.in_transaction()); + apply_update_parameters(&mut ctx, "transaction_id=abc123").unwrap(); + assert!(ctx.in_transaction()); + assert!(ctx.args.extra.iter().any(|e| e == "transaction_id=abc123")); + } + + #[test] + fn test_apply_update_parameters_multiple_pairs() { + let mut ctx = make_context(); + apply_update_parameters(&mut ctx, "transaction_id=abc123,transaction_sequence_id=1").unwrap(); + assert!(ctx.in_transaction()); + assert!(ctx.args.extra.iter().any(|e| e == "transaction_id=abc123")); + assert!(ctx.args.extra.iter().any(|e| e == "transaction_sequence_id=1")); + } + + #[test] + fn test_apply_update_parameters_empty_string_is_noop() { + let mut ctx = make_context(); + let before = ctx.args.extra.clone(); + apply_update_parameters(&mut ctx, "").unwrap(); + assert_eq!(ctx.args.extra, before); + assert!(!ctx.in_transaction()); + } + + #[test] + fn test_apply_update_parameters_trailing_comma_ignored() { + let mut ctx = make_context(); + apply_update_parameters(&mut ctx, "transaction_id=x,").unwrap(); + // trailing comma produces an empty token, which is skipped + assert!(ctx.in_transaction()); + assert_eq!(ctx.args.extra.iter().filter(|e| e.starts_with("transaction_id=")).count(), 1); + } + + #[test] + fn test_apply_update_parameters_preserves_existing_extras() { + let mut args = get_args().unwrap(); + args.extra = vec!["custom_param=hello".to_string()]; + let mut ctx = Context::new(args); + apply_update_parameters(&mut ctx, "transaction_id=abc").unwrap(); + assert!(ctx.args.extra.iter().any(|e| e == "custom_param=hello"), + "pre-existing extras must be preserved"); + assert!(ctx.in_transaction()); + } + + #[test] + fn test_remove_parameters_single_key() { + let mut ctx = make_context(); + apply_update_parameters(&mut ctx, "transaction_id=abc123,transaction_sequence_id=1").unwrap(); + assert!(ctx.in_transaction()); + + remove_parameters(&mut ctx, "transaction_id"); + assert!(!ctx.in_transaction()); + // sequence_id should still be present + assert!(ctx.args.extra.iter().any(|e| e.starts_with("transaction_sequence_id="))); + } + + #[test] + fn test_remove_parameters_multiple_keys() { + let mut ctx = make_context(); + apply_update_parameters(&mut ctx, "transaction_id=abc123,transaction_sequence_id=1").unwrap(); + + remove_parameters(&mut ctx, "transaction_id,transaction_sequence_id"); + assert!(!ctx.in_transaction()); + assert!(!ctx.args.extra.iter().any(|e| e.starts_with("transaction_sequence_id="))); + } + + #[test] + fn test_remove_parameters_preserves_unrelated_extras() { + let mut args = get_args().unwrap(); + args.extra = vec!["other_param=value".to_string()]; + let mut ctx = Context::new(args); + apply_update_parameters(&mut ctx, "transaction_id=abc123").unwrap(); + + remove_parameters(&mut ctx, "transaction_id"); + assert!(!ctx.in_transaction()); + assert!(ctx.args.extra.iter().any(|e| e == "other_param=value"), + "unrelated extras must survive remove_parameters"); + } + + #[test] + fn test_remove_parameters_exact_prefix_match() { + // "transaction_id" removal must not affect "transaction_id_extra=..." + // because the prefix checked is "transaction_id=" which won't match. + let mut args = get_args().unwrap(); + args.extra = vec!["transaction_id_extra=value".to_string()]; + let mut ctx = Context::new(args); + apply_update_parameters(&mut ctx, "transaction_id=abc").unwrap(); + + remove_parameters(&mut ctx, "transaction_id"); + assert!(!ctx.in_transaction()); + assert!(ctx.args.extra.iter().any(|e| e == "transaction_id_extra=value"), + "transaction_id_extra must not be removed when only transaction_id is removed"); + } + + #[test] + fn test_remove_parameters_empty_string_is_noop() { + let mut ctx = make_context(); + apply_update_parameters(&mut ctx, "transaction_id=abc").unwrap(); + let before = ctx.args.extra.clone(); + remove_parameters(&mut ctx, ""); + assert_eq!(ctx.args.extra, before); + } + + #[test] + fn test_in_transaction_lifecycle() { + let mut ctx = make_context(); + + // 1. Initially no transaction. + assert!(!ctx.in_transaction()); + + // 2. Server sends Firebolt-Update-Parameters after BEGIN. + apply_update_parameters(&mut ctx, "transaction_id=deadbeef,transaction_sequence_id=0").unwrap(); + assert!(ctx.in_transaction()); + + // 3. Sequence counter increments mid-transaction (server-side increment). + apply_update_parameters(&mut ctx, "transaction_sequence_id=1").unwrap(); + assert!(ctx.in_transaction(), "still in transaction after sequence bump"); + + // 4. Server sends Firebolt-Reset-Session after COMMIT/ROLLBACK. + remove_parameters(&mut ctx, "transaction_id,transaction_sequence_id"); + assert!(!ctx.in_transaction(), "transaction must be cleared after reset"); + + // 5. A brand-new transaction can begin. + apply_update_parameters(&mut ctx, "transaction_id=cafebabe,transaction_sequence_id=0").unwrap(); + assert!(ctx.in_transaction(), "second transaction must register correctly"); + } + + #[test] + fn test_chunk_decoder_pure_ascii() { + let mut dec = ChunkDecoder::new(); + assert_eq!(dec.feed(b"hello ").unwrap(), "hello "); + assert_eq!(dec.feed(b"world").unwrap(), "world"); + assert!(dec.finish().is_ok()); + } + + #[test] + fn test_chunk_decoder_multibyte_not_split() { + let mut dec = ChunkDecoder::new(); + // "─" is e2 94 80 in UTF-8 + assert_eq!(dec.feed("hello ─ world".as_bytes()).unwrap(), "hello ─ world"); + assert!(dec.finish().is_ok()); + } + + #[test] + fn test_chunk_decoder_split_two_byte_char() { + let mut dec = ChunkDecoder::new(); + // "ñ" is c3 b1 — split across two chunks + let full = "señor".as_bytes(); + let split = full.iter().position(|&b| b == 0xc3).unwrap(); + assert_eq!(dec.feed(&full[..split]).unwrap(), "se"); + assert_eq!(dec.feed(&full[split..]).unwrap(), "ñor"); + assert!(dec.finish().is_ok()); + } + + #[test] + fn test_chunk_decoder_split_three_byte_char_after_first() { + let mut dec = ChunkDecoder::new(); + // "┴" is e2 94 b4 — split after first byte + let full = "ab┴cd".as_bytes(); + let split = 2; // "ab" is 2 bytes, then e2 starts + assert_eq!(dec.feed(&full[..split + 1]).unwrap(), "ab"); + assert_eq!(dec.feed(&full[split + 1..]).unwrap(), "┴cd"); + assert!(dec.finish().is_ok()); + } + + #[test] + fn test_chunk_decoder_split_three_byte_char_after_second() { + let mut dec = ChunkDecoder::new(); + // "┴" is e2 94 b4 — split after second byte + let full = "ab┴cd".as_bytes(); + let split = 2; // "ab" is 2 bytes, then e2 94 b4 + assert_eq!(dec.feed(&full[..split + 2]).unwrap(), "ab"); + assert_eq!(dec.feed(&full[split + 2..]).unwrap(), "┴cd"); + assert!(dec.finish().is_ok()); + } + + #[test] + fn test_chunk_decoder_split_four_byte_char() { + let mut dec = ChunkDecoder::new(); + // "😀" is f0 9f 98 80 — split after first byte + let full = "a😀b".as_bytes(); + assert_eq!(dec.feed(&full[..2]).unwrap(), "a"); + assert_eq!(dec.feed(&full[2..]).unwrap(), "😀b"); + assert!(dec.finish().is_ok()); + } + + #[test] + fn test_chunk_decoder_many_splits_across_chunks() { + let mut dec = ChunkDecoder::new(); + let input = "──┬──┴──"; + let bytes = input.as_bytes(); + let mut output = String::new(); + for byte in bytes { + output.push_str(&dec.feed(&[*byte]).unwrap()); + } + assert!(dec.finish().is_ok()); + assert_eq!(output, input); + } + + #[test] + fn test_chunk_decoder_truncated_stream() { + let mut dec = ChunkDecoder::new(); + // Feed the first byte of a 3-byte sequence, then end stream + assert_eq!(dec.feed(&[0xe2]).unwrap(), ""); + let err = dec.finish().unwrap_err(); + assert!(err.contains("trailing bytes"), "error: {}", err); + assert!(err.contains("e2"), "error should contain hex: {}", err); + } + + #[test] + fn test_chunk_decoder_invalid_byte() { + let mut dec = ChunkDecoder::new(); + // 0xff is never valid in UTF-8 + let err = dec.feed(&[b'a', b'b', 0xff, b'c']).unwrap_err(); + assert!(err.contains("Invalid UTF-8"), "error: {}", err); + assert!(err.contains("[ff]"), "error should bracket the bad byte: {}", err); + } + + #[test] + fn test_chunk_decoder_invalid_continuation() { + let mut dec = ChunkDecoder::new(); + // e2 followed by 0xff — not a valid continuation byte + let err = dec.feed(b"hello\xe2\xff").unwrap_err(); + assert!(err.contains("Invalid UTF-8"), "error: {}", err); + } + + #[test] + fn test_chunk_decoder_error_reports_total_position() { + let mut dec = ChunkDecoder::new(); + // First chunk: 10 valid ASCII bytes + assert_eq!(dec.feed(b"0123456789").unwrap(), "0123456789"); + // Second chunk: 5 valid bytes then invalid + let err = dec.feed(&[b'a', b'b', b'c', b'd', b'e', 0xff]).unwrap_err(); + assert!( + err.contains("at byte 15"), + "should report global byte position 15 (10+5): {}", + err + ); + } } diff --git a/src/table_renderer.rs b/src/table_renderer.rs new file mode 100644 index 0000000..d910529 --- /dev/null +++ b/src/table_renderer.rs @@ -0,0 +1,892 @@ +use crate::tui_msg::{TuiColor, TuiLine, TuiSpan}; +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Deserialize)] +#[serde(tag = "message_type")] +pub enum JsonLineMessage { + #[serde(rename = "START")] + Start { + result_columns: Vec, + // The following fields are part of the Firebolt JSONLines_Compact protocol + // and required for deserialization, but not used by the client renderer. + #[allow(dead_code)] + query_id: String, + #[allow(dead_code)] + request_id: String, + #[allow(dead_code)] + query_label: Option, + }, + #[serde(rename = "DATA")] + Data { data: Vec> }, + #[serde(rename = "FINISH_SUCCESSFULLY")] + FinishSuccessfully { statistics: Option }, + #[serde(rename = "FINISH_WITH_ERRORS")] + FinishWithErrors { errors: Vec }, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct ResultColumn { + pub name: String, + // Column type from Firebolt server (e.g., "bigint", "integer", "text"). + // Currently unused for rendering but available for future type-aware formatting. + // Required for deserialization. + #[allow(dead_code)] + #[serde(rename = "type")] + pub column_type: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct ErrorDetail { + pub description: String, +} + +#[derive(Clone, Debug)] +pub struct ParsedResult { + pub columns: Vec, + pub rows: Vec>, + pub statistics: Option, + pub errors: Option>, +} + +pub fn parse_jsonlines_compact(text: &str) -> Result> { + let mut columns: Vec = Vec::new(); + let mut all_rows: Vec> = Vec::new(); + let mut statistics: Option = None; + let mut errors: Option> = None; + + for line in text.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let message: JsonLineMessage = serde_json::from_str(trimmed)?; + + match message { + JsonLineMessage::Start { result_columns, .. } => { + columns = result_columns; + } + JsonLineMessage::Data { data } => { + all_rows.extend(data); + } + JsonLineMessage::FinishSuccessfully { statistics: stats } => { + statistics = stats; + } + JsonLineMessage::FinishWithErrors { errors: errs } => { + errors = Some(errs); + } + } + } + + Ok(ParsedResult { + columns, + rows: all_rows, + statistics, + errors, + }) +} + + +/// Format a serde_json::Value for table display. +/// +/// Handles Firebolt's JSONLines_Compact serialization format where different +/// Firebolt types are serialized to JSON in specific ways: +/// +/// **JSON Numbers** (rendered via `.to_string()`): +/// - INT, DOUBLE, REAL → JSON numbers (e.g., `42`, `3.14`) +/// +/// **JSON Strings** (rendered as-is without quotes): +/// - BIGINT → JSON strings to preserve precision (e.g., `"9223372036854775807"`) +/// - NUMERIC/DECIMAL → JSON strings for exact decimals (e.g., `"1.23"`) +/// - TEXT → JSON strings (e.g., `"regular text"`) +/// - DATE → ISO format strings (e.g., `"2026-02-06"`) +/// - TIMESTAMP → ISO-like strings with timezone (e.g., `"2026-02-06 15:35:34+00"`) +/// - BYTEA → Hex-encoded binary data (e.g., `"\\x48656c6c6f"`) +/// - GEOGRAPHY → WKB (Well-Known Binary) format in hex (e.g., `"0101000020E6..."`) +/// +/// **Other JSON Types**: +/// - ARRAY → JSON arrays (e.g., `[1,2,3]`) +/// - BOOLEAN → JSON booleans (e.g., `true`, `false`) +/// - NULL → JSON `null`, rendered as "NULL" string for SQL-style display +/// +/// Note: The `column_type` field in ResultColumn is available but currently unused. +/// It could be leveraged for future type-aware formatting (e.g., right-align numbers, +/// format dates differently). +fn format_value(value: &Value) -> String { + match value { + Value::Null => "NULL".to_string(), + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Array(_) | Value::Object(_) => { + let json_str = serde_json::to_string(value).unwrap_or_else(|_| "".to_string()); + // Truncate very long JSON (e.g., query_telemetry with hundreds of KB) + const MAX_JSON_LENGTH: usize = 1000; + if json_str.len() > MAX_JSON_LENGTH { + format!("{}... (truncated)", &json_str[..MAX_JSON_LENGTH]) + } else { + json_str + } + } + } +} + + +/// Format a serde_json::Value for CSV export. +/// +/// Similar to format_value(), but with CSV-specific differences: +/// - `Null` → empty string "" (CSV standard for null values) +/// - Other types formatted identically to format_value() +/// +/// Maintains Firebolt's serialization: BIGINT strings, NUMERIC strings, etc. +fn format_value_csv(val: &Value) -> String { + match val { + Value::Null => String::new(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + Value::String(s) => s.clone(), + Value::Array(_) | Value::Object(_) => val.to_string(), + } +} + +/// Escape a CSV field according to RFC 4180 +fn escape_csv_field(field: &str) -> String { + // Escape fields containing comma, quote, or newline + if field.contains(',') || field.contains('"') || field.contains('\n') { + format!("\"{}\"", field.replace('"', "\"\"")) + } else { + field.to_string() + } +} + +/// Convert ParsedResult to CSV format +pub fn write_result_as_csv( + writer: &mut W, + columns: &[ResultColumn], + rows: &[Vec], +) -> Result<(), Box> { + // Write CSV header + let header = columns.iter().map(|col| escape_csv_field(&col.name)).collect::>().join(","); + writeln!(writer, "{}", header)?; + + // Write data rows + for row in rows { + let row_str = row + .iter() + .map(|val| escape_csv_field(&format_value_csv(val))) + .collect::>() + .join(","); + writeln!(writer, "{}", row_str)?; + } + + Ok(()) +} + +// ── TUI-native table renderers ──────────────────────────────────────────────── +// These produce Vec directly, so the TUI can apply ratatui styles +// without any ANSI round-trip. + +/// Wrap a string (by char boundaries) into lines of at most `width` chars each, +/// padding every line to exactly `width` chars with spaces. +/// +/// Embedded `\n` characters are treated as hard line breaks: the string is split +/// on them first, then each segment is independently wrapped at `width` chars. +fn wrap_cell(s: &str, width: usize) -> Vec { + if width == 0 { + return vec![String::new()]; + } + let mut lines = Vec::new(); + for segment in s.split('\n') { + let chars: Vec = segment.chars().collect(); + if chars.is_empty() { + lines.push(" ".repeat(width)); + continue; + } + let mut start = 0; + while start < chars.len() { + let end = (start + width).min(chars.len()); + let slice: String = chars[start..end].iter().collect(); + let padded = format!("{: usize { + s.split('\n').map(|l| l.chars().count()).max().unwrap_or(0) +} + +/// How many terminal rows a cell needs at column width `w`, accounting for +/// embedded newlines as hard breaks and then word-wrap within each segment. +fn rows_needed_for_cell(s: &str, w: usize) -> usize { + if w == 0 { + return 1; + } + s.split('\n') + .map(|seg| { + let n = seg.chars().count(); + if n == 0 { 1 } else { (n + w - 1) / w } + }) + .sum::() + .max(1) +} + +/// Build a TUI border line using box-drawing characters. +/// Each column occupies `col_width + 2` chars (1 space padding on each side). +fn make_tui_border( + left: &str, + fill: &str, + mid: &str, + right: &str, + col_widths: &[usize], +) -> TuiLine { + let mut s = String::from(left); + for (i, &w) in col_widths.iter().enumerate() { + // fill repeated for col_width + 2 (padding on each side) + for _ in 0..w + 2 { + s.push_str(fill); + } + if i < col_widths.len() - 1 { + s.push_str(mid); + } + } + s.push_str(right); + TuiLine(vec![TuiSpan::plain(s)]) +} + +/// Decide column widths for horizontal layout. +/// Returns None if vertical format should be used instead. +fn decide_col_widths( + columns: &[ResultColumn], + formatted: &[Vec], + terminal_width: usize, + max_value_length: usize, +) -> Option> { + let n = columns.len(); + if n == 0 { + return Some(vec![]); + } + + // Overhead for N columns: 3*N + 1 (for "│ col │ col │" structure) + let overhead = 3 * n + 1; + let available = terminal_width.saturating_sub(overhead); + + // Single-column special case: always horizontal. + if n == 1 { + let effective_max = max_value_length.max(10_000); + let natural = { + let h = columns[0].name.chars().count(); + let d = formatted.iter().map(|r| r[0].chars().count()).max().unwrap_or(0); + h.max(d) + }; + // Cap natural width at (terminal_width * 4 / 5).max(10) + let cap = (terminal_width * 4 / 5).max(10); + let natural_capped = natural.min(cap); + let col_w = (terminal_width.saturating_sub(4)).max(1).min(natural_capped).max(1); + let _ = effective_max; // used conceptually for max_value_length + return Some(vec![col_w]); + } + + // Compute natural width for each column (capped at (terminal_width * 4/5).max(10)) + let cap = (terminal_width * 4 / 5).max(10); + let natural_widths: Vec = (0..n) + .map(|i| { + let h = columns[i].name.chars().count(); + // Use max line width (not total char count) so multi-line cells + // don't inflate the column width estimate. + let d = formatted.iter().map(|r| cell_display_width(&r[i])).max().unwrap_or(0); + h.max(d).min(cap) + }) + .collect(); + + let sum_natural: usize = natural_widths.iter().sum(); + + // If sum of natural widths <= available: use horizontal with natural widths. + if sum_natural <= available { + return Some(natural_widths); + } + + // If available / N < 10: use vertical format. + if available / n < 10 { + return None; + } + + // Compute minimum width per column: + // - At least 10 + // - Header wraps at most once: ceil(header_len / 2) + // - Content not taller than wide: ceil(sqrt(max_content_len)) + let min_widths: Vec = (0..n) + .map(|i| { + let header_len = columns[i].name.chars().count(); + let max_content = formatted.iter().map(|r| cell_display_width(&r[i])).max().unwrap_or(0); + let min_from_header = (header_len + 1) / 2; // ceil(header_len / 2) + let min_from_content = (max_content as f64).sqrt().ceil() as usize; + 10usize.max(min_from_header).max(min_from_content) + }) + .collect(); + + let sum_min: usize = min_widths.iter().sum(); + + // If sum of minimum widths > available: use vertical format. + if sum_min > available { + return None; + } + + // Distribute remaining space: start with min_widths, distribute extra space. + let mut col_widths = min_widths.clone(); + let mut remaining = available - sum_min; + + // Each pass adds 1 to each column that hasn't reached its natural width. + loop { + if remaining == 0 { + break; + } + let mut added = 0usize; + for i in 0..n { + if remaining == 0 { + break; + } + if col_widths[i] < natural_widths[i] { + col_widths[i] += 1; + remaining -= 1; + added += 1; + } + } + if added == 0 { + // All columns at natural width. + break; + } + } + + // Switch to vertical if any cell needs more wrap rows than there are columns. + // A tall narrow cell is a sign that vertical layout will be much more readable. + let any_cell_too_tall = formatted.iter().any(|row| { + (0..n).any(|i| { + let w = col_widths[i].max(1); + rows_needed_for_cell(&row[i], w) > n + }) + }); + if any_cell_too_tall { + return None; + } + + Some(col_widths) +} + +/// Render a horizontal table with given column widths as TUI lines. +/// Handles multi-line cell wrapping with box-drawing borders. +fn render_horizontal_tui( + columns: &[ResultColumn], + rows: &[Vec], + formatted: &[Vec], + col_widths: &[usize], +) -> Vec { + let n = columns.len(); + if n == 0 { + return vec![]; + } + + // Check if any cell (header or data) needs wrapping. + let any_wrap = { + let header_wraps = (0..n).any(|i| columns[i].name.chars().count() > col_widths[i]); + let data_wraps = formatted.iter().any(|row| { + (0..n).any(|i| { + // Wraps if the widest line exceeds the column, or if there are + // embedded newlines that force additional rows. + cell_display_width(&row[i]) > col_widths[i] || row[i].contains('\n') + }) + }); + header_wraps || data_wraps + }; + + let mut lines = Vec::new(); + + // Top border: ┌──────┬──────┐ + lines.push(make_tui_border("┌", "─", "┬", "┐", col_widths)); + + // Header row (multi-line aware). + let header_cells: Vec> = (0..n) + .map(|i| wrap_cell(&columns[i].name, col_widths[i])) + .collect(); + let header_height = header_cells.iter().map(|c| c.len()).max().unwrap_or(1); + for line_idx in 0..header_height { + let mut spans: Vec = vec![TuiSpan::plain("│ ")]; + for (i, cell_lines) in header_cells.iter().enumerate() { + let text = cell_lines.get(line_idx).cloned().unwrap_or_else(|| " ".repeat(col_widths[i])); + spans.push(TuiSpan::styled(text, TuiColor::Cyan, true)); + if i < n - 1 { + spans.push(TuiSpan::plain(" │ ")); + } + } + spans.push(TuiSpan::plain(" │")); + lines.push(TuiLine(spans)); + } + + // Header separator: ╞═══════╪═══════╡ + lines.push(make_tui_border("╞", "═", "╪", "╡", col_widths)); + + // Data rows. + for (row_idx, row_cells) in formatted.iter().enumerate() { + let null_flags: Vec = (0..n) + .map(|i| rows.get(row_idx).and_then(|r| r.get(i)).map(|v| v.is_null()).unwrap_or(false)) + .collect(); + + let wrapped: Vec> = (0..n) + .map(|i| wrap_cell(&row_cells[i], col_widths[i])) + .collect(); + let row_height = wrapped.iter().map(|c| c.len()).max().unwrap_or(1); + + for line_idx in 0..row_height { + let mut spans: Vec = vec![TuiSpan::plain("│ ")]; + for (i, cell_lines) in wrapped.iter().enumerate() { + let text = cell_lines.get(line_idx).cloned().unwrap_or_else(|| " ".repeat(col_widths[i])); + if null_flags[i] && line_idx == 0 { + spans.push(TuiSpan::styled(text, TuiColor::DarkGray, false)); + } else if null_flags[i] { + // Padding lines for NULL cells also in dark gray + spans.push(TuiSpan::styled(text, TuiColor::DarkGray, false)); + } else { + spans.push(TuiSpan::plain(text)); + } + if i < n - 1 { + spans.push(TuiSpan::plain(" │ ")); + } + } + spans.push(TuiSpan::plain(" │")); + lines.push(TuiLine(spans)); + } + + // Row separator between data rows (only if any cell wraps). + if any_wrap && row_idx < formatted.len() - 1 { + lines.push(make_tui_border("├", "─", "┼", "┤", col_widths)); + } + } + + // Bottom border: └──────┴──────┘ + lines.push(make_tui_border("└", "─", "┴", "┘", col_widths)); + + lines +} + +/// Truncate a formatted value to at most `max_len` chars (char count), appending "..." if truncated. +fn truncate_to_chars(s: String, max_len: usize) -> String { + let char_count = s.chars().count(); + if char_count > max_len { + let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect(); + format!("{}...", truncated) + } else { + s + } +} + +/// Format and truncate a cell value, using char-count for length measurement. +/// +/// `\n` is preserved so `wrap_cell` can render it as an actual line break. +/// Other control characters (tabs, carriage returns, etc.) are replaced with +/// spaces to avoid corrupting ratatui's line rendering. +fn fmt_cell(val: &Value, max_value_length: usize) -> String { + let s = format_value(val); + let s = s.chars().map(|c| if c.is_control() && c != '\n' { ' ' } else { c }).collect::(); + truncate_to_chars(s, max_value_length) +} + +/// Render a table in auto mode (horizontal if it fits, vertical otherwise). +/// Uses smart column width computation and box-drawing borders. +pub fn render_table_to_tui_lines( + columns: &[ResultColumn], + rows: &[Vec], + terminal_width: u16, + max_value_length: usize, +) -> Vec { + if columns.is_empty() { + return vec![]; + } + let n = columns.len(); + let tw = terminal_width as usize; + + // For single-column tables, use a larger max to show more content. + let effective_max = if n == 1 { max_value_length.max(10_000) } else { max_value_length }; + + // Format all cell values using char-count for truncation. + let formatted: Vec> = rows + .iter() + .map(|row| (0..n).map(|i| fmt_cell(row.get(i).unwrap_or(&Value::Null), effective_max)).collect()) + .collect(); + + match decide_col_widths(columns, &formatted, tw, effective_max) { + Some(col_widths) => render_horizontal_tui(columns, rows, &formatted, &col_widths), + None => render_vertical_table_to_tui_lines(columns, rows, terminal_width, max_value_length), + } +} + +/// Render a vertical (two-column: name | value) table as TUI lines. +/// For each original row, renders a mini 2-column table with column names and values. +pub fn render_vertical_table_to_tui_lines( + columns: &[ResultColumn], + rows: &[Vec], + terminal_width: u16, + max_value_length: usize, +) -> Vec { + let tw = terminal_width as usize; + + // Name column width: max of all column name lengths, clamped to [10, 30]. + let max_name_len = columns.iter().map(|c| c.name.chars().count()).max().unwrap_or(0); + let name_col_w = max_name_len.min(30).max(10); + + // Value column width: remaining space minus overhead (2 cols = 3*2+1 = 7 overhead). + let val_col_w = tw.saturating_sub(name_col_w + 7).max(10); + + let col_widths = [name_col_w, val_col_w]; + + let mut lines = Vec::new(); + + for (row_idx, row) in rows.iter().enumerate() { + // "Row N:" label (cyan + bold) as a standalone TuiLine. + lines.push(TuiLine(vec![TuiSpan::styled( + format!("Row {}:", row_idx + 1), + TuiColor::Cyan, + true, + )])); + + // Check if any name or value wraps within this row's mini-table. + let any_wrap_in_row = columns.iter().enumerate().any(|(col_idx, col)| { + let name_wraps = col.name.chars().count() > name_col_w; + let val = fmt_cell(row.get(col_idx).unwrap_or(&Value::Null), max_value_length); + let val_wraps = cell_display_width(&val) > val_col_w || val.contains('\n'); + name_wraps || val_wraps + }); + + // Top border of mini-table. + lines.push(make_tui_border("┌", "─", "┬", "┐", &col_widths)); + + for (field_idx, col) in columns.iter().enumerate() { + let is_null = row.get(field_idx).map(|v| v.is_null()).unwrap_or(false); + let val = fmt_cell(row.get(field_idx).unwrap_or(&Value::Null), max_value_length); + + let name_lines = wrap_cell(&col.name, name_col_w); + let val_lines = wrap_cell(&val, val_col_w); + let field_height = name_lines.len().max(val_lines.len()); + + for line_idx in 0..field_height { + let name_text = name_lines.get(line_idx).cloned().unwrap_or_else(|| " ".repeat(name_col_w)); + let val_text = val_lines.get(line_idx).cloned().unwrap_or_else(|| " ".repeat(val_col_w)); + + let mut spans: Vec = vec![TuiSpan::plain("│ ")]; + spans.push(TuiSpan::styled(name_text, TuiColor::Cyan, true)); + spans.push(TuiSpan::plain(" │ ")); + if is_null { + spans.push(TuiSpan::styled(val_text, TuiColor::DarkGray, false)); + } else { + spans.push(TuiSpan::plain(val_text)); + } + spans.push(TuiSpan::plain(" │")); + lines.push(TuiLine(spans)); + } + + // Row separator between fields (only if any name or value wraps). + if any_wrap_in_row && field_idx < columns.len() - 1 { + lines.push(make_tui_border("├", "─", "┼", "┤", &col_widths)); + } + } + + // Bottom border of mini-table. + lines.push(make_tui_border("└", "─", "┴", "┘", &col_widths)); + + // Blank line between rows (except after last). + if row_idx < rows.len() - 1 { + lines.push(TuiLine(vec![TuiSpan::plain(String::new())])); + } + } + + lines +} + +/// Render a table always in horizontal format (forced horizontal). +/// If column widths would normally cause vertical layout, falls back to equal-share widths. +pub fn render_horizontal_forced_tui_lines( + columns: &[ResultColumn], + rows: &[Vec], + terminal_width: u16, + max_value_length: usize, +) -> Vec { + if columns.is_empty() { + return vec![]; + } + let n = columns.len(); + let tw = terminal_width as usize; + + let effective_max = if n == 1 { max_value_length.max(10_000) } else { max_value_length }; + + let formatted: Vec> = rows + .iter() + .map(|row| (0..n).map(|i| fmt_cell(row.get(i).unwrap_or(&Value::Null), effective_max)).collect()) + .collect(); + + let col_widths = match decide_col_widths(columns, &formatted, tw, effective_max) { + Some(widths) => widths, + None => { + // Fall back to equal-share column widths. + let overhead = 3 * n + 1; + let available = tw.saturating_sub(overhead); + let equal_w = (available / n).max(1); + vec![equal_w; n] + } + }; + + render_horizontal_tui(columns, rows, &formatted, &col_widths) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple_jsonlines() { + let input = r#"{"message_type":"START","query_id":"123","request_id":"456","query_label":null,"result_columns":[{"name":"col1","type":"integer"},{"name":"col2","type":"text"}]} +{"message_type":"DATA","data":[[1,"hello"],[2,"world"]]} +{"message_type":"FINISH_SUCCESSFULLY","statistics":{"elapsed":0.123}}"#; + + let result = parse_jsonlines_compact(input).unwrap(); + assert_eq!(result.columns.len(), 2); + assert_eq!(result.rows.len(), 2); + assert!(result.errors.is_none()); + } + + #[test] + fn test_parse_multiple_data_messages() { + let input = r#"{"message_type":"START","query_id":"123","request_id":"456","query_label":null,"result_columns":[{"name":"id","type":"integer"}]} +{"message_type":"DATA","data":[[1],[2]]} +{"message_type":"DATA","data":[[3],[4]]} +{"message_type":"FINISH_SUCCESSFULLY","statistics":{}}"#; + + let result = parse_jsonlines_compact(input).unwrap(); + assert_eq!(result.rows.len(), 4); + } + + #[test] + fn test_parse_with_errors() { + let input = r#"{"message_type":"START","query_id":"123","request_id":"456","query_label":null,"result_columns":[]} +{"message_type":"FINISH_WITH_ERRORS","errors":[{"description":"Syntax error"}]}"#; + + let result = parse_jsonlines_compact(input).unwrap(); + assert!(result.errors.is_some()); + assert_eq!(result.errors.unwrap()[0].description, "Syntax error"); + } + + #[test] + fn test_format_value_firebolt_bigint() { + // BIGINT arrives as JSON string (not number) to preserve precision + let bigint_max = Value::String("9223372036854775807".to_string()); + assert_eq!(format_value(&bigint_max), "9223372036854775807"); + + let bigint_min = Value::String("-9223372036854775808".to_string()); + assert_eq!(format_value(&bigint_min), "-9223372036854775808"); + + // Should display as-is without quotes + let output = format_value(&bigint_max); + assert!(!output.starts_with('"'), "BIGINT should not have quotes in display"); + } + + #[test] + fn test_format_value_firebolt_numeric() { + // NUMERIC/DECIMAL arrives as JSON string for exact precision + let decimal = Value::String("1.23".to_string()); + assert_eq!(format_value(&decimal), "1.23"); + + let large_decimal = Value::String("12345678901234567890.123456789".to_string()); + assert_eq!(format_value(&large_decimal), "12345678901234567890.123456789"); + } + + #[test] + fn test_format_value_firebolt_integers() { + // INT arrives as JSON number + let int_val = Value::Number(42.into()); + assert_eq!(format_value(&int_val), "42"); + + let negative = Value::Number((-42).into()); + assert_eq!(format_value(&negative), "-42"); + + // INT min/max values + let int_max = Value::Number(2147483647.into()); + assert_eq!(format_value(&int_max), "2147483647"); + + let int_min = Value::Number((-2147483648).into()); + assert_eq!(format_value(&int_min), "-2147483648"); + } + + #[test] + fn test_format_value_firebolt_floats() { + // DOUBLE/REAL arrive as JSON numbers + let pi = serde_json::Number::from_f64(3.14159).unwrap(); + let formatted = format_value(&Value::Number(pi)); + assert!(formatted.starts_with("3.14"), "Got: {}", formatted); + + // Integer-valued float keeps decimal point + let one = serde_json::Number::from_f64(1.0).unwrap(); + assert_eq!(format_value(&Value::Number(one)), "1.0"); + } + + #[test] + fn test_format_value_firebolt_temporal() { + // DATE arrives as ISO format string + let date = Value::String("2026-02-06".to_string()); + assert_eq!(format_value(&date), "2026-02-06"); + + // TIMESTAMP arrives as ISO-like format with timezone + let timestamp = Value::String("2026-02-06 15:35:34.519403+00".to_string()); + assert_eq!(format_value(×tamp), "2026-02-06 15:35:34.519403+00"); + } + + #[test] + fn test_format_value_firebolt_arrays() { + // ARRAY arrives as JSON array + let int_array = serde_json::json!([1, 2, 3]); + let formatted = format_value(&int_array); + assert!(formatted.contains("1")); + assert!(formatted.contains("2")); + assert!(formatted.contains("3")); + + // Empty array + let empty = Value::Array(vec![]); + assert_eq!(format_value(&empty), "[]"); + } + + #[test] + fn test_format_value_firebolt_text() { + // Regular TEXT + let text = Value::String("regular text".to_string()); + assert_eq!(format_value(&text), "regular text"); + + // TEXT with JSON-like content (still a string, not parsed) + let json_text = Value::String("{\"key\": \"value\"}".to_string()); + assert_eq!(format_value(&json_text), "{\"key\": \"value\"}"); + + // Unicode text + let unicode = Value::String("🔥 emoji text".to_string()); + assert_eq!(format_value(&unicode), "🔥 emoji text"); + } + + #[test] + fn test_format_value_firebolt_bytea() { + // BYTEA arrives as hex-encoded string + let bytea = Value::String("\\x48656c6c6f".to_string()); + assert_eq!(format_value(&bytea), "\\x48656c6c6f"); + + // Should display as-is without interpretation + let output = format_value(&bytea); + assert!(output.starts_with("\\x"), "BYTEA should preserve hex encoding"); + } + + #[test] + fn test_format_value_firebolt_geography() { + // GEOGRAPHY arrives as WKB (Well-Known Binary) format in hex + let geo_point = Value::String("0101000020E6100000FEFFFFFFFFFFEF3F0000000000000040".to_string()); + assert_eq!(format_value(&geo_point), "0101000020E6100000FEFFFFFFFFFFEF3F0000000000000040"); + + // Should display as hex string without interpretation + let output = format_value(&geo_point); + assert!(output.len() > 20, "GEOGRAPHY hex strings are long"); + assert!(!output.starts_with('"'), "Should not have quotes in display"); + } + + #[test] + fn test_format_value_firebolt_null_and_bool() { + // NULL rendered as "NULL" string for clarity + assert_eq!(format_value(&Value::Null), "NULL"); + + // BOOLEAN rendered as lowercase + assert_eq!(format_value(&Value::Bool(true)), "true"); + assert_eq!(format_value(&Value::Bool(false)), "false"); + } + + #[test] + fn test_format_value_csv_null_handling() { + // In CSV, NULL should be empty string (not "NULL") + assert_eq!(format_value_csv(&Value::Null), ""); + + // But in table format, NULL should be "NULL" + assert_eq!(format_value(&Value::Null), "NULL"); + + // BIGINT strings should work in CSV too + let bigint = Value::String("9223372036854775807".to_string()); + assert_eq!(format_value_csv(&bigint), "9223372036854775807"); + } + + #[test] + fn test_format_value_null() { + assert_eq!(format_value(&Value::Null), "NULL"); + } + + #[test] + fn test_format_value_string() { + assert_eq!(format_value(&Value::String("test".to_string())), "test"); + } + + #[test] + fn test_json_truncation() { + // Create a very large JSON array + let large_array = Value::Array(vec![Value::String("x".repeat(500)); 10]); + let rows = vec![vec![large_array]]; + + let formatted = format_value(&rows[0][0]); + + // Should be truncated + assert!(formatted.contains("truncated") || formatted.len() < 5000); + } + + #[test] + fn test_write_result_as_csv() { + let columns = vec![ + ResultColumn { + name: "id".to_string(), + column_type: "int".to_string(), + }, + ResultColumn { + name: "name".to_string(), + column_type: "text".to_string(), + }, + ]; + let rows = vec![ + vec![Value::Number(1.into()), Value::String("Alice".to_string())], + vec![Value::Number(2.into()), Value::String("Bob".to_string())], + ]; + + let mut output = Vec::new(); + write_result_as_csv(&mut output, &columns, &rows).unwrap(); + + let csv_str = String::from_utf8(output).unwrap(); + assert!(csv_str.contains("id,name")); + assert!(csv_str.contains("1,Alice")); + assert!(csv_str.contains("2,Bob")); + } + + #[test] + fn test_csv_escaping() { + let columns = vec![ResultColumn { + name: "col".to_string(), + column_type: "text".to_string(), + }]; + let rows = vec![ + vec![Value::String("has,comma".to_string())], + vec![Value::String("has\"quote".to_string())], + vec![Value::String("has\nnewline".to_string())], + ]; + + let mut output = Vec::new(); + write_result_as_csv(&mut output, &columns, &rows).unwrap(); + + let csv_str = String::from_utf8(output).unwrap(); + assert!(csv_str.contains("\"has,comma\"")); + assert!(csv_str.contains("\"has\"\"quote\"")); + assert!(csv_str.contains("\"has\nnewline\"")); + } + +} diff --git a/src/transport.rs b/src/transport.rs new file mode 100644 index 0000000..4613fd0 --- /dev/null +++ b/src/transport.rs @@ -0,0 +1,191 @@ +use bytes::Bytes; +use http_body_util::{BodyExt, Full}; +use hyper::body::Incoming; +use hyper::client::conn::http1; +use hyper::header::HeaderMap; +use hyper::StatusCode; +use hyper_util::rt::TokioIo; +use tokio::net::UnixStream; + +use crate::{FIREBOLT_PROTOCOL_VERSION, USER_AGENT}; + +/// HTTP/1.1 response received over a Unix domain socket. +pub struct UnixResponse { + status: StatusCode, + headers: HeaderMap, + body: Incoming, +} + +impl UnixResponse { + pub fn status(&self) -> StatusCode { + self.status + } + + pub fn headers(&self) -> &HeaderMap { + &self.headers + } + + /// Read the next chunk from the response body, skipping empty data frames and trailers. + pub async fn chunk( + &mut self, + ) -> Result, Box> { + loop { + match self.body.frame().await { + None => return Ok(None), + Some(Err(e)) => return Err(Box::new(e)), + Some(Ok(frame)) => { + if let Ok(data) = frame.into_data() { + if !data.is_empty() { + return Ok(Some(data)); + } + // empty data frame — keep reading + } + // trailer frame — keep reading + } + } + } + } + + pub async fn text(mut self) -> Result> { + let mut s = String::new(); + while let Some(chunk) = self.chunk().await? { + s.push_str(std::str::from_utf8(&chunk)?); + } + Ok(s) + } +} + +/// Unified response from either a TCP (reqwest) or Unix-socket (hyper) HTTP request. +pub enum Response { + Http(reqwest::Response), + Unix(UnixResponse), +} + +impl Response { + pub fn status(&self) -> StatusCode { + match self { + Self::Http(r) => r.status(), + Self::Unix(r) => r.status(), + } + } + + pub fn headers(&self) -> &HeaderMap { + match self { + Self::Http(r) => r.headers(), + Self::Unix(r) => r.headers(), + } + } + + /// Stream the next chunk from the response body. + pub async fn chunk( + &mut self, + ) -> Result, Box> { + match self { + Self::Http(r) => Ok(r.chunk().await?), + Self::Unix(r) => r.chunk().await, + } + } + + /// Collect the entire body as a UTF-8 string. + pub async fn text(self) -> Result> { + match self { + Self::Http(r) => Ok(r.text().await?), + Self::Unix(r) => r.text().await, + } + } +} + +/// Send a POST request, routing through a Unix domain socket when `unix_socket` is set, +/// or falling back to standard TCP via reqwest. +pub async fn post( + url: &str, + unix_socket: Option<&str>, + body: String, + authorization: Option, + machine_query: bool, +) -> Result> { + match unix_socket.filter(|s| !s.is_empty()) { + Some(path) => unix_post(path, url, body, authorization, machine_query) + .await + .map(Response::Unix), + None => tcp_post(url, body, authorization, machine_query) + .await + .map(Response::Http), + } +} + +async fn tcp_post( + url: &str, + body: String, + authorization: Option, + machine_query: bool, +) -> Result> { + let mut request = reqwest::Client::builder() + .http2_keep_alive_timeout(std::time::Duration::from_secs(3600)) + .http2_keep_alive_interval(Some(std::time::Duration::from_secs(60))) + .http2_keep_alive_while_idle(false) + .tcp_keepalive(Some(std::time::Duration::from_secs(60))) + .build()? + .post(url) + .header("user-agent", USER_AGENT) + .header("Firebolt-Protocol-Version", FIREBOLT_PROTOCOL_VERSION); + + if machine_query { + request = request.header("Firebolt-Machine-Query", "true"); + } + if let Some(auth) = authorization { + request = request.header("authorization", auth); + } + + Ok(request.body(body).send().await?) +} + +async fn unix_post( + socket_path: &str, + url: &str, + body: String, + authorization: Option, + machine_query: bool, +) -> Result> { + // Extract path+query from the URL; the unix socket ignores host and port. + let parsed = reqwest::Url::parse(url)?; + let path_and_query = match parsed.query() { + Some(q) => format!("{}?{}", parsed.path(), q), + None => parsed.path().to_string(), + }; + let host = parsed.host_str().unwrap_or("localhost"); + + // Connect to the Unix domain socket. + let stream = UnixStream::connect(socket_path).await?; + let io = TokioIo::new(stream); + let (mut sender, conn) = http1::handshake(io).await?; + tokio::spawn(async move { + let _ = conn.await; + }); + + // Build and send the HTTP/1.1 POST request. + let mut builder = hyper::Request::builder() + .method("POST") + .uri(path_and_query) + .header("host", host) + .header("user-agent", USER_AGENT) + .header("Firebolt-Protocol-Version", FIREBOLT_PROTOCOL_VERSION) + .header("content-length", body.len().to_string()); + + if machine_query { + builder = builder.header("Firebolt-Machine-Query", "true"); + } + if let Some(auth) = authorization { + builder = builder.header("authorization", auth); + } + + let request = builder.body(Full::new(Bytes::from(body)))?; + let response = sender.send_request(request).await?; + let (parts, body) = response.into_parts(); + + Ok(UnixResponse { + status: parts.status, + headers: parts.headers, + body, + }) +} diff --git a/src/tui/completion_popup.rs b/src/tui/completion_popup.rs new file mode 100644 index 0000000..d380ffd --- /dev/null +++ b/src/tui/completion_popup.rs @@ -0,0 +1,211 @@ +/// Floating tab-completion popup rendered above the input pane. +/// +/// The popup is a bordered `List` widget positioned so that the left edge +/// aligns with the first character of the word being completed. +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem}, +}; + +use crate::completion::CompletionItem; + +/// State for an open completion popup session. +#[allow(dead_code)] +pub struct CompletionState { + /// All available completion items. + pub items: Vec, + /// Index of the currently highlighted item. + pub selected: usize, + /// Index of the first visible item (scroll position). + pub scroll_offset: usize, + /// Byte offset in the full textarea content where the partial word starts. + pub word_start_byte: usize, + /// Visual column (0-based) of the word start within its line. + /// Used for popup positioning. + pub word_start_col: usize, + /// Textarea cursor row (0-based) when completion was triggered. + pub cursor_row: usize, + /// When true, move the cursor one step forward after insertion to place it + /// inside an already-existing `(` that follows the completed word. + pub advance_past_paren: bool, +} + +impl CompletionState { + pub fn new( + items: Vec, + word_start_byte: usize, + word_start_col: usize, + cursor_row: usize, + advance_past_paren: bool, + ) -> Self { + Self { + items, + selected: 0, + scroll_offset: 0, + word_start_byte, + word_start_col, + cursor_row, + advance_past_paren, + } + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + pub fn selected_item(&self) -> Option<&CompletionItem> { + self.items.get(self.selected) + } + + /// Advance selection to the next item (wrapping), scrolling the viewport. + pub fn next(&mut self) { + if !self.items.is_empty() { + self.selected = (self.selected + 1) % self.items.len(); + self.ensure_visible(); + } + } + + /// Jump selection directly to `index` (clamped to valid range). + pub fn select_at(&mut self, index: usize) { + if index < self.items.len() { + self.selected = index; + self.ensure_visible(); + } + } + + /// Move selection to the previous item (wrapping), scrolling the viewport. + pub fn prev(&mut self) { + if !self.items.is_empty() { + self.selected = self.selected.checked_sub(1).unwrap_or(self.items.len() - 1); + self.ensure_visible(); + } + } + + /// Adjust scroll_offset so that `selected` is within the visible window. + fn ensure_visible(&mut self) { + let vis = MAX_VISIBLE as usize; + if self.selected < self.scroll_offset { + self.scroll_offset = self.selected; + } else if self.selected >= self.scroll_offset + vis { + self.scroll_offset = self.selected - vis + 1; + } + } +} + +// ── Layout helpers ─────────────────────────────────────────────────────────── + +const MAX_VISIBLE: u16 = 10; +const DESCRIPTION_WIDTH: u16 = 10; // "table" / "column" / "function" / "schema" +const MIN_VALUE_WIDTH: u16 = 16; +const BORDER_OVERHEAD: u16 = 2; // left + right border + +/// Compute the area for the completion popup. +/// +/// `input_area` — the full input pane rect (borders included). +/// `textarea_x` — x coordinate of the left edge of the textarea column +/// (i.e. after the "❯ " prompt columns). +/// `total` — the full terminal area, for clamping. +pub fn popup_area( + state: &CompletionState, + input_area: Rect, + textarea_x: u16, + total: Rect, + bottom_offset: u16, +) -> Rect { + let visible = (state.items.len() as u16).min(MAX_VISIBLE); + let popup_h = visible + BORDER_OVERHEAD; + + // Compute preferred width from longest item name + let max_name = state + .items + .iter() + .map(|i| i.value.len() as u16) + .max() + .unwrap_or(MIN_VALUE_WIDTH); + let popup_w = (max_name + DESCRIPTION_WIDTH + BORDER_OVERHEAD + 2) + .max(MIN_VALUE_WIDTH + DESCRIPTION_WIDTH + BORDER_OVERHEAD + 2) + .min(total.width.saturating_sub(2)); + + // Popup x: align with the word start column + let preferred_x = textarea_x + state.word_start_col as u16; + let x = if preferred_x + popup_w > total.width { + total.width.saturating_sub(popup_w) + } else { + preferred_x + }; + + // Popup y: just above the input pane (and above any bottom_offset, e.g. + // the signature hint popup that sits directly above the input pane). + let effective_bottom = input_area.y.saturating_sub(bottom_offset); + let y = effective_bottom.saturating_sub(popup_h); + + Rect::new(x, y.max(0), popup_w, popup_h) +} + +// ── Rendering ──────────────────────────────────────────────────────────────── + +/// Render the completion popup to the frame. +pub fn render(state: &CompletionState, area: Rect, f: &mut ratatui::Frame) { + // Clear the background so the popup appears floating + f.render_widget(Clear, area); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let inner = block.inner(area); + let value_width = inner.width.saturating_sub(DESCRIPTION_WIDTH) as usize; + + // Build list items — only the visible window starting at scroll_offset + let items: Vec = state + .items + .iter() + .enumerate() + .skip(state.scroll_offset) + .take(inner.height as usize) + .map(|(idx, item)| { + let is_selected = idx == state.selected; + + // Left: item value (truncated if needed) + let name: String = item.value.chars().take(value_width).collect(); + let pad = value_width.saturating_sub(name.len()); + let padded_name = format!("{}{}", name, " ".repeat(pad)); + + // Right: description (right-aligned in a fixed-width column) + let desc: String = item + .description + .chars() + .take(DESCRIPTION_WIDTH as usize - 1) + .collect(); + let desc_pad = (DESCRIPTION_WIDTH as usize - 1).saturating_sub(desc.len()); + let padded_desc = format!("{}{} ", " ".repeat(desc_pad), desc); + + let (name_style, desc_style) = if is_selected { + ( + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD), + Style::default().fg(Color::Black).bg(Color::Cyan), + ) + } else { + ( + Style::default().fg(Color::White), + Style::default().fg(Color::DarkGray), + ) + }; + + let line = Line::from(vec![ + Span::styled(padded_name, name_style), + Span::styled(padded_desc, desc_style), + ]); + ListItem::new(line) + }) + .collect(); + + let list = List::new(items); + f.render_widget(block, area); + f.render_widget(list, inner); +} diff --git a/src/tui/fuzzy_popup.rs b/src/tui/fuzzy_popup.rs new file mode 100644 index 0000000..d5cfe82 --- /dev/null +++ b/src/tui/fuzzy_popup.rs @@ -0,0 +1,161 @@ +/// Ctrl+Space fuzzy schema search overlay. +/// +/// A full-width popup that covers the input pane and lower part of the output +/// pane. The user types a search query and sees ranked schema items in real time. +use ratatui::{ + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, Paragraph}, +}; + +use crate::completion::fuzzy_completer::FuzzyItem; + +const MAX_VISIBLE: u16 = 12; +const POPUP_HEIGHT: u16 = MAX_VISIBLE + 4; // list + search line + 2 borders + +/// State for an open fuzzy-search session. +pub struct FuzzyState { + /// Current search query typed by the user. + pub query: String, + /// Ranked items from the fuzzy completer (refreshed on each query change). + pub items: Vec, + /// Currently highlighted item index. + pub selected: usize, + /// Index of the first visible item (scroll position). + pub scroll_offset: usize, +} + +impl FuzzyState { + pub fn new() -> Self { + Self { + query: String::new(), + items: Vec::new(), + selected: 0, + scroll_offset: 0, + } + } + + pub fn push_char(&mut self, c: char) { + self.query.push(c); + self.selected = 0; + self.scroll_offset = 0; + } + + pub fn pop_char(&mut self) { + self.query.pop(); + self.selected = 0; + self.scroll_offset = 0; + } + + pub fn next(&mut self) { + if !self.items.is_empty() { + self.selected = (self.selected + 1) % self.items.len(); + self.ensure_visible(); + } + } + + pub fn prev(&mut self) { + if !self.items.is_empty() { + self.selected = self.selected.checked_sub(1).unwrap_or(self.items.len() - 1); + self.ensure_visible(); + } + } + + pub fn selected_item(&self) -> Option<&FuzzyItem> { + self.items.get(self.selected) + } + + /// Adjust scroll_offset so that `selected` is within the visible window. + fn ensure_visible(&mut self) { + let vis = MAX_VISIBLE as usize; + if self.selected < self.scroll_offset { + self.scroll_offset = self.selected; + } else if self.selected >= self.scroll_offset + vis { + self.scroll_offset = self.selected - vis + 1; + } + } +} + +/// Compute the popup rect: full-width, anchored just above the input pane. +pub fn popup_area(input_area: Rect, total: Rect) -> Rect { + let h = POPUP_HEIGHT.min(input_area.y); // can't go above y=0 + let y = input_area.y.saturating_sub(h); + Rect::new(0, y, total.width, h) +} + +/// Render the fuzzy popup to the frame. +pub fn render(state: &FuzzyState, area: Rect, f: &mut ratatui::Frame) { + f.render_widget(Clear, area); + + let block = Block::default() + .title(" Fuzzy Schema Search (Enter accept · Esc close) ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Magenta)); + + let inner = block.inner(area); + f.render_widget(block, area); + + // Split inner: search line at top, results below + let chunks = Layout::vertical([ + Constraint::Length(1), // search input line + Constraint::Min(0), // results list + ]) + .split(inner); + + // ── Search line ──────────────────────────────────────────────────────── + let search_line = Line::from(vec![ + Span::styled("/ ", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)), + Span::styled(state.query.clone(), Style::default().fg(Color::White)), + Span::styled("█", Style::default().fg(Color::Magenta)), // cursor indicator + ]); + f.render_widget(Paragraph::new(search_line), chunks[0]); + + // ── Results list ─────────────────────────────────────────────────────── + let desc_w = 16usize; + let label_w = chunks[1].width as usize - desc_w - 1; + + let items: Vec = state + .items + .iter() + .enumerate() + .skip(state.scroll_offset) + .take(chunks[1].height as usize) + .map(|(idx, item)| { + let is_sel = idx == state.selected; + + // Truncate/pad label + let label: String = item.label.chars().take(label_w).collect(); + let pad = label_w.saturating_sub(label.len()); + let padded = format!("{}{}", label, " ".repeat(pad)); + + // Right-aligned description + let desc: String = item.description.chars().take(desc_w - 1).collect(); + let dpd = format!("{:>width$}", desc, width = desc_w - 1); + + let (l_style, d_style) = if is_sel { + ( + Style::default() + .fg(Color::Black) + .bg(Color::Magenta) + .add_modifier(Modifier::BOLD), + Style::default().fg(Color::Black).bg(Color::Magenta), + ) + } else { + ( + Style::default().fg(Color::White), + Style::default().fg(Color::DarkGray), + ) + }; + + let line = Line::from(vec![ + Span::styled(padded, l_style), + Span::styled(" ", if is_sel { l_style } else { Style::default() }), + Span::styled(dpd, d_style), + ]); + ListItem::new(line) + }) + .collect(); + + f.render_widget(List::new(items), chunks[1]); +} diff --git a/src/tui/history.rs b/src/tui/history.rs new file mode 100644 index 0000000..7ae6726 --- /dev/null +++ b/src/tui/history.rs @@ -0,0 +1,166 @@ +use std::fs; +use std::io::Write; +use std::path::PathBuf; + +/// File-backed history with cursor-based navigation. +/// Entries are stored one per line; embedded newlines are escaped as `\n`. +pub struct History { + entries: Vec, + /// None = not currently navigating. Some(i) = showing entries[i]. + cursor: Option, + /// The text that was in the editor when navigation started. + saved_current: String, + path: PathBuf, +} + +impl History { + pub fn new(path: PathBuf) -> Self { + Self { + entries: Vec::new(), + cursor: None, + saved_current: String::new(), + path, + } + } + + /// Load history from disk. Each line is one entry; `\n` in entries is stored as `\\n`. + pub fn load(&mut self) -> Result<(), Box> { + if !self.path.exists() { + return Ok(()); + } + let content = fs::read_to_string(&self.path)?; + self.entries = content + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| l.replace("\\n", "\n")) + .collect(); + // Keep at most 10 000 entries (trim oldest) + if self.entries.len() > 10_000 { + let start = self.entries.len() - 10_000; + self.entries = self.entries[start..].to_vec(); + } + Ok(()) + } + + /// Append a single entry to the history file without rewriting the whole file. + fn append_to_file(&self, entry: &str) -> Result<(), Box> { + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&self.path)?; + let stored = entry.replace('\n', "\\n"); + writeln!(file, "{}", stored)?; + Ok(()) + } + + /// Add a new entry. Deduplicates consecutive identical entries. + pub fn add(&mut self, entry: String) { + if entry.trim().is_empty() { + return; + } + if self.entries.last().map(|e| e == &entry).unwrap_or(false) { + self.cursor = None; + return; + } + self.entries.push(entry.clone()); + if self.entries.len() > 10_000 { + self.entries.remove(0); + } + self.cursor = None; + let _ = self.append_to_file(&entry); + } + + /// Read-only view of all history entries (oldest first). + pub fn entries(&self) -> &[String] { + &self.entries + } + + /// Navigate backward (older). Returns the entry to display, or `None` if history is empty. + /// `current` is the text currently in the editor (saved so we can restore it on forward). + pub fn go_back(&mut self, current: &str) -> Option { + if self.entries.is_empty() { + return None; + } + match self.cursor { + None => { + self.saved_current = current.to_string(); + self.cursor = Some(self.entries.len() - 1); + } + Some(0) => {} // already at oldest – stay + Some(i) => { + self.cursor = Some(i - 1); + } + } + self.cursor.map(|i| self.entries[i].clone()) + } + + /// Navigate forward (newer). Returns the entry to display, or the saved current text + /// when we return past the newest entry. + pub fn go_forward(&mut self) -> Option { + match self.cursor { + None => None, + Some(i) if i + 1 >= self.entries.len() => { + self.cursor = None; + Some(self.saved_current.clone()) + } + Some(i) => { + self.cursor = Some(i + 1); + Some(self.entries[i + 1].clone()) + } + } + } + + /// Reset navigation state (called when user types something new). + pub fn reset_navigation(&mut self) { + self.cursor = None; + self.saved_current.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn dummy() -> History { + History::new(PathBuf::from("/tmp/test_fb_history_dummy")) + } + + #[test] + fn test_add_and_navigate() { + let mut h = dummy(); + h.add("SELECT 1;".to_string()); + h.add("SELECT 2;".to_string()); + h.add("SELECT 3;".to_string()); + + assert_eq!(h.go_back(""), Some("SELECT 3;".to_string())); + assert_eq!(h.go_back(""), Some("SELECT 2;".to_string())); + assert_eq!(h.go_back(""), Some("SELECT 1;".to_string())); + // Already at oldest – stays + assert_eq!(h.go_back(""), Some("SELECT 1;".to_string())); + + // Forward brings us back + assert_eq!(h.go_forward(), Some("SELECT 2;".to_string())); + assert_eq!(h.go_forward(), Some("SELECT 3;".to_string())); + // Past newest – returns saved current + assert_eq!(h.go_forward(), Some("".to_string())); + } + + #[test] + fn test_dedup_consecutive() { + let mut h = dummy(); + h.add("SELECT 1;".to_string()); + h.add("SELECT 1;".to_string()); + assert_eq!(h.entries.len(), 1); + } + + #[test] + fn test_reset_navigation() { + let mut h = dummy(); + h.add("a".to_string()); + h.add("b".to_string()); + h.go_back("current"); + h.reset_navigation(); + assert!(h.cursor.is_none()); + } +} diff --git a/src/tui/history_search.rs b/src/tui/history_search.rs new file mode 100644 index 0000000..0c76e3e --- /dev/null +++ b/src/tui/history_search.rs @@ -0,0 +1,317 @@ +/// Incremental reverse-history-search state (Ctrl+R). +/// +/// Displayed as a bottom-up popup: the search box is at the bottom and +/// matches are listed above it with the most-recent entry at the bottom. +pub const MAX_VISIBLE: usize = 16; + +pub struct HistorySearch { + /// The characters the user has typed so far. + query: String, + /// Byte offset of the insertion cursor within `query`. + cursor_pos: usize, + /// Indices into `History::entries` of all matching entries, most-recent-first. + all_matches: Vec, + /// Which match is currently highlighted (index into `all_matches`, 0 = most recent). + selected: usize, + /// Scroll offset: index of the entry shown at the bottom of the visible window. + scroll_offset: usize, + /// Text that was in the textarea when search began; restored on Escape. + saved_content: String, +} + +impl HistorySearch { + /// Create a new search session. Immediately populates all matches so that + /// the popup shows recent history before the user types anything. + pub fn new(saved_content: String, entries: &[String]) -> Self { + let mut s = Self { + query: String::new(), + cursor_pos: 0, + all_matches: Vec::new(), + selected: 0, + scroll_offset: 0, + saved_content, + }; + s.recompute(entries); + s + } + + #[allow(dead_code)] + pub fn query(&self) -> &str { + &self.query + } + + /// The part of the query before the cursor (for rendering). + pub fn query_before_cursor(&self) -> &str { + &self.query[..self.cursor_pos] + } + + /// The part of the query at and after the cursor (for rendering). + pub fn query_after_cursor(&self) -> &str { + &self.query[self.cursor_pos..] + } + + pub fn all_matches(&self) -> &[usize] { + &self.all_matches + } + + pub fn selected(&self) -> usize { + self.selected + } + + pub fn scroll_offset(&self) -> usize { + self.scroll_offset + } + + /// The currently highlighted history entry, if any. + pub fn matched<'a>(&self, entries: &'a [String]) -> Option<&'a str> { + self.all_matches.get(self.selected).map(|&i| entries[i].as_str()) + } + + /// The textarea content to restore when the user presses Escape. + pub fn saved_content(&self) -> &str { + &self.saved_content + } + + // ── Private helpers ────────────────────────────────────────────────────── + + fn recompute(&mut self, entries: &[String]) { + let q = self.query.to_lowercase(); + let mut result = Vec::new(); + let mut last_matched = ""; + for i in (0..entries.len()).rev() { + let entry = entries[i].as_str(); + if q.is_empty() || entry.to_lowercase().contains(&q) { + // Skip only if identical to the previous entry that also matched — + // i.e. consecutive duplicates within the filtered result list. + if entry != last_matched { + result.push(i); + } + last_matched = entry; + } + } + self.all_matches = result; + self.selected = 0; + self.scroll_offset = 0; + } + + /// Keep `selected` within the visible scroll window. + fn ensure_visible(&mut self, visible_rows: usize) { + let h = visible_rows.max(1); + if self.selected < self.scroll_offset { + self.scroll_offset = self.selected; + } else if self.selected >= self.scroll_offset + h { + self.scroll_offset = self.selected - h + 1; + } + } + + // ── Public mutation ────────────────────────────────────────────────────── + + pub fn push_char(&mut self, c: char, entries: &[String]) { + self.query.insert(self.cursor_pos, c); + self.cursor_pos += c.len_utf8(); + self.recompute(entries); + } + + pub fn pop_char(&mut self, entries: &[String]) { + if self.cursor_pos == 0 { + return; + } + // Find start of the character just before cursor_pos. + let new_pos = self.query[..self.cursor_pos] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + self.query.remove(new_pos); + self.cursor_pos = new_pos; + self.recompute(entries); + } + + /// Move the cursor one character to the left. + pub fn move_cursor_left(&mut self) { + if self.cursor_pos == 0 { + return; + } + self.cursor_pos = self.query[..self.cursor_pos] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + } + + /// Move the cursor one character to the right. + pub fn move_cursor_right(&mut self) { + if self.cursor_pos < self.query.len() { + let ch = self.query[self.cursor_pos..].chars().next().unwrap(); + self.cursor_pos += ch.len_utf8(); + } + } + + /// Move the cursor to the start of the query (Ctrl+A). + pub fn cursor_to_start(&mut self) { + self.cursor_pos = 0; + } + + /// Move selection to the next older match (Up arrow / Ctrl+R). + /// `visible_rows` is the height of the list area for scroll tracking. + pub fn select_older(&mut self, visible_rows: usize) { + if self.selected + 1 < self.all_matches.len() { + self.selected += 1; + self.ensure_visible(visible_rows); + } + } + + /// Move selection to the next newer match (Down arrow). + pub fn select_newer(&mut self, visible_rows: usize) { + if self.selected > 0 { + self.selected -= 1; + self.ensure_visible(visible_rows); + } + } + + /// Alias for Ctrl+R (cycles to next older match). + pub fn search_older(&mut self, _entries: &[String], visible_rows: usize) { + self.select_older(visible_rows); + } +} + +/// Format a (possibly multi-line) history entry for single-line display. +/// Newlines are replaced with `↵` and the result is truncated with `(...)`. +pub fn format_entry_oneline(entry: &str, max_chars: usize) -> String { + // Join lines with ↵ separator + let joined: String = { + let mut parts = entry.lines(); + let first = parts.next().unwrap_or("").trim_end(); + let mut s = first.to_string(); + for part in parts { + let trimmed = part.trim(); + if !trimmed.is_empty() { + s.push_str(" ↵ "); + s.push_str(trimmed); + } + } + s + }; + + if joined.chars().count() <= max_chars { + joined + } else { + let suffix = " (...)"; + let take = max_chars.saturating_sub(suffix.chars().count()); + let truncated: String = joined.chars().take(take).collect(); + format!("{}{}", truncated, suffix) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entries() -> Vec { + vec![ + "SELECT 1;".to_string(), + "SELECT * FROM orders;".to_string(), + "SELECT * FROM users;".to_string(), + "INSERT INTO orders VALUES (1);".to_string(), + ] + } + + #[test] + fn test_new_shows_all_entries() { + let s = HistorySearch::new(String::new(), &entries()); + let e = entries(); + // No query → all entries match; most-recent (INSERT) is first + assert_eq!(s.all_matches().len(), 4); + assert_eq!(s.matched(&e), Some("INSERT INTO orders VALUES (1);")); + } + + #[test] + fn test_push_narrows_match() { + let mut s = HistorySearch::new(String::new(), &entries()); + let e = entries(); + s.push_char('s', &e); + s.push_char('e', &e); + s.push_char('l', &e); + assert_eq!(s.all_matches().len(), 3); + assert_eq!(s.matched(&e), Some("SELECT * FROM users;")); + } + + #[test] + fn test_select_older_cycles() { + let mut s = HistorySearch::new(String::new(), &entries()); + let e = entries(); + s.push_char('s', &e); + s.push_char('e', &e); + s.push_char('l', &e); + assert_eq!(s.matched(&e), Some("SELECT * FROM users;")); + s.select_older(100); + assert_eq!(s.matched(&e), Some("SELECT * FROM orders;")); + s.select_older(100); + assert_eq!(s.matched(&e), Some("SELECT 1;")); + // At last match — stays there + s.select_older(100); + assert_eq!(s.matched(&e), Some("SELECT 1;")); + } + + #[test] + fn test_select_newer() { + let mut s = HistorySearch::new(String::new(), &entries()); + let e = entries(); + s.push_char('s', &e); + s.push_char('e', &e); + s.push_char('l', &e); + s.select_older(100); + s.select_older(100); + assert_eq!(s.matched(&e), Some("SELECT 1;")); + s.select_newer(100); + assert_eq!(s.matched(&e), Some("SELECT * FROM orders;")); + } + + #[test] + fn test_saved_content() { + let s = HistorySearch::new("my draft query".to_string(), &[]); + assert_eq!(s.saved_content(), "my draft query"); + } + + #[test] + fn test_format_entry_oneline_single() { + assert_eq!(format_entry_oneline("SELECT 1;", 80), "SELECT 1;"); + } + + #[test] + fn test_format_entry_oneline_multiline() { + let s = format_entry_oneline("SELECT 1\nFROM t", 80); + assert!(s.contains("↵")); + assert!(s.contains("FROM t")); + } + + #[test] + fn test_format_entry_oneline_truncate() { + let long = "SELECT very_long_column_name FROM some_table WHERE condition IS TRUE"; + let result = format_entry_oneline(long, 20); + assert!(result.ends_with("(...)")); + assert!(result.chars().count() <= 20); + } + + #[test] + fn test_consecutive_duplicates_in_results() { + let e = vec![ + "SELECT 1;".to_string(), + "SELECT 2;".to_string(), + "SELECT 2;".to_string(), // consecutive with above → collapsed + "SELECT 2;".to_string(), // consecutive → collapsed + "SELECT 1;".to_string(), // not consecutive with SELECT 1 at index 0 + // because SELECT 2 entries are between them + ]; + // No filter: result list is SELECT 1, SELECT 2, SELECT 2, SELECT 2, SELECT 1 + // (most-recent-first). After consecutive dedup in results: SELECT 1, SELECT 2, SELECT 1 + let s = HistorySearch::new(String::new(), &e); + assert_eq!(s.all_matches().len(), 3); + + // Filter "2": only SELECT 2 entries match → all three are consecutive in + // the result list → collapsed to one. + let mut s2 = HistorySearch::new(String::new(), &e); + s2.push_char('2', &e); + assert_eq!(s2.all_matches().len(), 1); + } +} diff --git a/src/tui/layout.rs b/src/tui/layout.rs new file mode 100644 index 0000000..4302dfd --- /dev/null +++ b/src/tui/layout.rs @@ -0,0 +1,49 @@ +use ratatui::layout::{Constraint, Direction, Layout, Rect}; + +/// The three panes of the TUI. +pub struct AppLayout { + pub output: Rect, + pub input: Rect, + pub status: Rect, +} + +/// Rows always reserved at the bottom for the input area (5 content + 2 borders). +/// The output pane never encroaches into this space, even when the textarea is +/// only one line tall. When the textarea grows past 5 lines it starts to push +/// the output pane upward. +const RESERVED_INPUT: u16 = 5; + +/// Compute a 4-pane vertical layout: output / spacer / input / status. +/// +/// `input_height` is the *actual* (tight) height of the input pane including +/// its borders. A transparent spacer is inserted above the input pane so that +/// the bottom `RESERVED_INPUT` rows are always reserved, keeping the output +/// pane stable while the textarea is small. +pub fn compute_layout(area: Rect, input_height: u16) -> AppLayout { + const STATUS_HEIGHT: u16 = 1; + + // Hard ceiling: never let the input+spacer eat more than area/3 + let max_input = area.height.saturating_sub(STATUS_HEIGHT + 3); + let input_h = input_height.clamp(3, max_input.max(3)); + + // How many rows are reserved at the bottom (at least RESERVED_INPUT, but + // clamped so the output pane always keeps at least 3 rows). + let reserved = RESERVED_INPUT.max(input_h).min(max_input.max(input_h)); + let spacer = reserved.saturating_sub(input_h); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(1), // output pane – takes remaining space + Constraint::Length(input_h), // input pane (anchored at top of reserved area) + Constraint::Length(spacer), // transparent spacer below input + Constraint::Length(STATUS_HEIGHT), // status bar + ]) + .split(area); + + AppLayout { + output: chunks[0], + input: chunks[1], + status: chunks[3], + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..d28e3b7 --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,3441 @@ +pub mod completion_popup; +pub mod fuzzy_popup; +pub mod history; +pub mod history_search; +pub mod layout; +pub mod output_pane; +pub mod signature_hint; + +use std::sync::Arc; +use std::time::{Duration, Instant}; + + +use crossterm::{ + event::{ + self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, + Event, KeyCode, KeyboardEnhancementFlags, KeyModifiers, MouseEventKind, + PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, + }, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Terminal, +}; +use crate::tui_msg::TuiMsg; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; +use tui_textarea::{CursorMove, Input, TextArea}; + +use crate::completion::context_analyzer::ContextAnalyzer; +use crate::completion::fuzzy_completer::FuzzyCompleter; +use crate::completion::schema_cache::SchemaCache; +use crate::completion::usage_tracker::UsageTracker; +use crate::completion::SqlCompleter; +use crate::context::Context; +use crate::highlight::SqlHighlighter; +use crate::meta_commands::handle_meta_command; +use crate::query::{dot_command, query, set_args, try_split_queries, unset_args, validate_setting}; +use crate::viewer::open_csvlens_viewer; + +use completion_popup::CompletionState; +use fuzzy_popup::FuzzyState; +use history::History; +use history_search::HistorySearch; +use layout::compute_layout; +use output_pane::OutputPane; + +const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +/// Parse `/benchmark [N] ` arguments. +/// Returns `(n_runs, query_text)` where `n_runs` defaults to 3. +fn parse_benchmark_args(cmd: &str) -> (usize, String) { + let rest = cmd + .strip_prefix("/benchmark") + .unwrap_or(cmd) + .trim() + .to_string(); + + // Try to parse an optional numeric run count at the start + let mut chars = rest.splitn(2, |c: char| c.is_whitespace()); + if let Some(first) = chars.next() { + if let Ok(n) = first.parse::() { + let query = chars.next().unwrap_or("").trim().to_string(); + return (n.max(1), query); + } + } + + // No number — entire rest is the query, default 3 runs + (3, rest) +} + +/// Return the first `max_lines` lines of `sql`. +/// If the input has more lines, an `…` line is appended so the caller can see +/// that the content was cut. +fn sql_preview(sql: &str, max_lines: usize) -> String { + let lines: Vec<&str> = sql.lines().collect(); + if lines.len() <= max_lines { + lines.join("\n") + } else { + let mut preview = lines[..max_lines].to_vec(); + preview.push("…"); + preview.join("\n") + } +} + +/// Strip a single layer of matching outer single or double quotes from `s`. +/// `"my file.sql"` → `my file.sql`; `'foo'` → `foo`; `bare` → `bare`. +fn strip_outer_quotes(s: &str) -> &str { + if s.len() >= 2 { + let b = s.as_bytes(); + if (b[0] == b'"' && b[b.len() - 1] == b'"') + || (b[0] == b'\'' && b[b.len() - 1] == b'\'') + { + return &s[1..s.len() - 1]; + } + } + s +} + +/// Read the file at `path`, expanding a leading `~` to the home directory. +/// Returns the file contents on success or a human-readable error string. +fn read_file_content(path: &str) -> Result { + let expanded: std::borrow::Cow = if path.starts_with('~') { + if let Some(home) = dirs::home_dir() { + std::borrow::Cow::Owned(format!("{}{}", home.display(), &path[1..])) + } else { + std::borrow::Cow::Borrowed(path) + } + } else { + std::borrow::Cow::Borrowed(path) + }; + std::fs::read_to_string(expanded.as_ref()) + .map_err(|e| format!("Error: could not read '{}': {}", path, e)) +} + +/// Return the column index in `first_line` where the "query / file" argument +/// starts for `/run`, `/benchmark [N]`, and `/watch [N]` commands. +/// +/// For `/benchmark` and `/watch`, an optional leading integer (the run count / +/// interval) is skipped; the return value points at the first character of the +/// actual query or `@` token. +/// +/// Only matches when SQL is on the same line as the command (space-separated). +/// Use `slash_cmd_sql_offset` when SQL may be on the next line. +fn find_slash_arg_col(first_line: &str) -> Option { + if first_line.starts_with("/run ") { + return Some("/run ".len()); + } + for prefix in ["/benchmark ", "/watch "] { + if let Some(rest) = first_line.strip_prefix(prefix) { + let base = prefix.len(); + let first_tok = rest.split_whitespace().next().unwrap_or(""); + if !first_tok.is_empty() && first_tok.parse::().is_ok() { + // Skip past the numeric token and trailing whitespace. + let after_num = rest[first_tok.len()..].trim_start(); + let skip = rest.len() - after_num.len(); + return Some(base + skip); + } + return Some(base); + } + } + None +} + +/// Return the byte offset within `full` (textarea lines joined with `\n`) +/// where the SQL argument of a slash command starts. +/// +/// Handles both layouts: +/// - SQL on the same line: `/benchmark 5 SELECT …` → points at `S` +/// - SQL on the next line: `/benchmark 5\nSELECT …` → points at `S` +/// +/// For `/benchmark` and `/watch` an optional leading integer is consumed. +/// Returns `None` when `first_line` is not a recognised command or no SQL +/// follows the prefix. +fn slash_cmd_sql_offset(first_line: &str, has_next_line: bool) -> Option { + // (command, whether to skip an optional leading integer argument) + const CMDS: &[(&str, bool)] = &[ + ("/benchmark", true), + ("/watch", true), + ("/run", false), + ]; + for &(cmd, has_count) in CMDS { + if !first_line.starts_with(cmd) { + continue; + } + let after_cmd = &first_line[cmd.len()..]; + + if after_cmd.is_empty() { + // `/command` alone — SQL must be on the next line. + return if has_next_line { Some(first_line.len() + 1) } else { None }; + } + if !after_cmd.starts_with(' ') { + // Not this command (e.g. `/benchmarkfoo`). + continue; + } + + // Skip leading whitespace after the command name. + let stripped = after_cmd.trim_start(); + let spaces = after_cmd.len() - stripped.len(); + let mut offset = cmd.len() + spaces; + + // Optionally skip a leading integer count (`/benchmark N`, `/watch N`). + if has_count { + let tok = stripped.split_whitespace().next().unwrap_or(""); + if tok.parse::().is_ok() { + let after_num = &stripped[tok.len()..]; + let more_spaces = after_num.len() - after_num.trim_start().len(); + offset += tok.len() + more_spaces; + } + } + + return if offset >= first_line.len() { + // The whole first line was consumed by the prefix; SQL is on the + // next line (or there is none). + if has_next_line { Some(first_line.len() + 1) } else { None } + } else { + Some(offset) + }; + } + None +} + +/// Parse `/watch [N] ` arguments. +/// Returns `(interval_secs, query_text)` where `interval_secs` defaults to 5. +fn parse_watch_args(cmd: &str) -> (u64, String) { + let rest = cmd + .strip_prefix("/watch") + .unwrap_or(cmd) + .trim() + .to_string(); + + let mut parts = rest.splitn(2, |c: char| c.is_whitespace()); + if let Some(first) = parts.next() { + if let Ok(n) = first.parse::() { + let query = parts.next().unwrap_or("").trim().to_string(); + return (n.max(1), query); + } + } + (5, rest) +} + +/// Build file-path completion candidates for the partial path `partial`. +/// Directories are listed with a trailing `/` and description `"dir"`; +/// files with description `"file"`. Results are sorted: directories first, +/// then alphabetically within each group. +fn complete_file_paths(partial: &str) -> Vec { + use crate::completion::{CompletionItem, usage_tracker::ItemType}; + use std::path::Path; + + // Expand leading `~` + let expanded: std::borrow::Cow = if partial.starts_with('~') { + if let Some(home) = dirs::home_dir() { + std::borrow::Cow::Owned(format!("{}{}", home.display(), &partial[1..])) + } else { + std::borrow::Cow::Borrowed(partial) + } + } else { + std::borrow::Cow::Borrowed(partial) + }; + + let path = Path::new(expanded.as_ref()); + let (dir, prefix) = if expanded.ends_with('/') || expanded.ends_with(std::path::MAIN_SEPARATOR) { + (path, "") + } else { + let parent = path.parent().unwrap_or(Path::new(".")); + let fname = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(""); + (parent, fname) + }; + + let Ok(read_dir) = std::fs::read_dir(dir).or_else(|_| std::fs::read_dir(".")) else { + return Vec::new(); + }; + + let mut dirs_list: Vec = Vec::new(); + let mut files_list: Vec = Vec::new(); + + for entry in read_dir.flatten() { + let name = match entry.file_name().into_string() { + Ok(n) => n, + Err(_) => continue, + }; + if !name.starts_with(prefix) { + continue; + } + + // Reconstruct the value to insert (keep the directory prefix typed so far). + // Derive from `partial` (before `~`-expansion) so completions stay in the + // form the user typed (e.g. `~/` rather than `/home/user/`). + let dir_prefix: &str = match partial.rfind('/') { + Some(pos) => &partial[..=pos], + None => "", + }; + + let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); + if is_dir { + dirs_list.push(CompletionItem { + value: format!("{}{}/", dir_prefix, name), + description: "dir".to_string(), + item_type: ItemType::Table, + }); + } else { + files_list.push(CompletionItem { + value: format!("{}{}", dir_prefix, name), + description: "file".to_string(), + item_type: ItemType::Table, + }); + } + } + + dirs_list.sort_by(|a, b| a.value.cmp(&b.value)); + files_list.sort_by(|a, b| a.value.cmp(&b.value)); + dirs_list.extend(files_list); + dirs_list +} + +/// If `set_query` looks like `set = ` and `` is a known +/// client-side dot-command setting, return a suggestion hint string. +/// Called after server-side validation fails so the user is nudged toward +/// the correct syntax. +fn dot_command_hint(set_query: &str) -> Option { + use once_cell::sync::Lazy; + use regex::Regex; + static SET_KEY_RE: Lazy = Lazy::new(|| { + Regex::new(r"(?i)^set\s+(\w+)\s*=").unwrap() + }); + let q = set_query.trim().trim_end_matches(';').trim(); + let caps = SET_KEY_RE.captures(q)?; + let key = caps.get(1)?.as_str().to_lowercase(); + match key.as_str() { + "completion" => Some( + "Use '.completion = on|off' to control client-side tab completion".to_string(), + ), + _ => None, + } +} + +/// Expand a command alias to a full SQL query, returning `None` if the command +/// is not a known alias. +/// +/// Adding a new alias: add a branch to the `match` below. +fn expand_command_alias(cmd: &str) -> Option { + let parts: Vec<&str> = cmd.splitn(3, char::is_whitespace).collect(); + match parts[0] { + "/qh" => { + // /qh [limit] [since_minutes] + let limit = parts.get(1).and_then(|s| s.parse::().ok()).unwrap_or(100); + let since_minutes = parts.get(2).and_then(|s| s.parse::().ok()).unwrap_or(60); + Some(format!( + "SELECT * FROM information_schema.engine_user_query_history \ + WHERE start_time > now() - interval '{since_minutes} minutes' \ + ORDER BY start_time DESC LIMIT {limit};" + )) + } + _ => None, + } +} + +/// Percent-decode a `key=value` setting string for display. +/// Splits on the first `=` so only the value part is decoded. +fn url_decode_setting(s: &str) -> String { + if let Some((key, val)) = s.split_once('=') { + let decoded = urlencoding::decode(val) + .map(|v| v.into_owned()) + .unwrap_or_else(|_| val.to_string()); + format!("{}={}", key, decoded) + } else { + s.to_string() + } +} + +/// If the byte at `cursor_byte` in `text` is a bracket (`(`, `)`, `[`, `]`), +/// scan forward or backward (counting nesting) and return the byte offset of +/// the matching bracket. Returns `None` if the cursor is not on a bracket or +/// no match exists in the text. +/// +/// The search deliberately ignores string/comment context for simplicity; +/// occasional false highlights inside string literals are acceptable. +fn find_matching_paren(text: &str, cursor_byte: usize) -> Option { + let bytes = text.as_bytes(); + if cursor_byte >= bytes.len() { + return None; + } + // `same` = character at cursor (e.g. `(`). + // `other` = the bracket we are searching for (e.g. `)`). + // `forward` = search direction. + // + // In both directions: encountering `same` means more nesting (depth++), + // encountering `other` means less nesting (depth--); match when depth==0. + let (same, other, forward) = match bytes[cursor_byte] { + b'(' => (b'(', b')', true), + b')' => (b')', b'(', false), + b'[' => (b'[', b']', true), + b']' => (b']', b'[', false), + _ => return None, + }; + let mut depth = 1i32; + if forward { + for i in (cursor_byte + 1)..bytes.len() { + if bytes[i] == same { + depth += 1; + } else if bytes[i] == other { + depth -= 1; + if depth == 0 { + return Some(i); + } + } + } + } else { + for i in (0..cursor_byte).rev() { + if bytes[i] == same { + depth += 1; + } else if bytes[i] == other { + depth -= 1; + if depth == 0 { + return Some(i); + } + } + } + } + None +} + +fn format_with_commas(n: u64) -> String { + let s = n.to_string(); + let mut result = String::with_capacity(s.len() + s.len() / 3); + for (i, c) in s.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + result.push(','); + } + result.push(c); + } + result.chars().rev().collect() +} + +pub struct TuiApp { + context: Context, + textarea: TextArea<'static>, + output: OutputPane, + history: History, + schema_cache: Arc, + completer: SqlCompleter, + fuzzy_completer: FuzzyCompleter, + highlighter: SqlHighlighter, + + // Query execution state + query_rx: Option>, + cancel_token: Option, + is_running: bool, + query_start: Option, + spinner_tick: u64, + running_hint: String, // e.g. "Showing first N rows — collecting remainder..." + progress_rows: u64, // rows received so far (streamed from query task) + /// Set when the current query returns a result with zero columns (DDL indicator). + /// Triggers an async schema-cache refresh on successful completion. + pending_schema_refresh: bool, + + /// Active tab-completion popup session; `None` when the popup is closed. + completion_state: Option, + + /// Active Ctrl+Space fuzzy search session; `None` when closed. + fuzzy_state: Option, + + /// Whether the Ctrl+H help overlay is visible (shown while key is held). + help_visible: bool, + + /// Active Ctrl+R reverse-search session; `None` when not searching. + history_search: Option, + + /// Current function-signature hint: (func_name, [sig1, sig2, ...]). + /// `None` when the cursor is not inside a function call. + signature_hint: Option<(String, Vec)>, + + /// Mirrors the scroll position that tui-textarea uses internally. + /// Kept in sync each render frame so `apply_textarea_highlights` can map + /// character columns to correct screen positions even when the textarea is + /// scrolled horizontally or vertically. + ta_row_top: u16, + ta_col_top: u16, + + /// Textarea screen area from the last render frame — used to hit-test mouse clicks. + last_textarea_area: ratatui::layout::Rect, + /// Completion popup screen area from the last render frame (`None` when no popup). + last_popup_rect: Option, + + // After leaving alt-screen for csvlens we need a full redraw + needs_clear: bool, + should_quit: bool, + pub has_error: bool, + + /// Persistent background channel: schema refresh tasks and background workers + /// route their output here instead of to stderr. Drained every event-loop tick. + bg_rx: mpsc::UnboundedReceiver, + + /// Whether the server was successfully reached during the last schema refresh. + /// False until the first successful refresh; drives the red status-bar indicator. + connected: bool, + /// True while a reconnection-pinger task is running (prevents duplicate pingers). + ping_active: bool, + + /// Temporary message shown in the status bar, with the time it was set. + flash_message: Option<(String, Instant)>, + + /// When true, open the csvlens viewer at the top of the next event-loop tick + /// (outside any event-handler so crossterm's global reader is fully idle). + pending_viewer: bool, + + /// When true, open $EDITOR at the top of the next event-loop tick. + pending_editor: bool, +} + +impl TuiApp { + pub fn new(mut context: Context, schema_cache: Arc) -> Self { + let usage_tracker = context + .usage_tracker + .clone() + .unwrap_or_else(|| Arc::new(UsageTracker::new(10))); + let completer = SqlCompleter::new( + schema_cache.clone(), + usage_tracker.clone(), + !context.args.no_completion, + ); + + let history_path = crate::utils::history_path().unwrap_or_default(); + let mut history = History::new(history_path); + let _ = history.load(); + + let textarea = Self::make_textarea(); + let highlighter = SqlHighlighter::new(!context.args.no_completion).unwrap_or_else(|_| { + SqlHighlighter::new(false).unwrap() + }); + let fuzzy_completer = FuzzyCompleter::new(schema_cache.clone(), usage_tracker); + + // Background channel: background tasks (schema refresh, etc.) send output + // here so it reaches the TUI output pane rather than going to stderr. + let (bg_tx, bg_rx) = mpsc::unbounded_channel::(); + context.tui_output_tx = Some(bg_tx); + + Self { + context, + textarea, + output: OutputPane::new(), + history, + schema_cache, + completer, + fuzzy_completer, + highlighter, + query_rx: None, + cancel_token: None, + is_running: false, + query_start: None, + spinner_tick: 0, + running_hint: String::new(), + progress_rows: 0, + pending_schema_refresh: false, + completion_state: None, + fuzzy_state: None, + help_visible: false, + history_search: None, + signature_hint: None, + ta_row_top: 0, + ta_col_top: 0, + last_textarea_area: ratatui::layout::Rect::default(), + last_popup_rect: None, + needs_clear: false, + should_quit: false, + has_error: false, + flash_message: None, + pending_viewer: false, + pending_editor: false, + bg_rx, + connected: true, // assume connected; turns red on first ConnectionStatus(false) + ping_active: false, + } + } + + fn make_textarea() -> TextArea<'static> { + let ta = TextArea::default(); + // Disable the current-line underline — terminal underlines inherit the + // foreground colour, which would make syntax-highlighted lines look + // inconsistent (each token segment would draw its underline in a + // different colour). + ta + } + + // ── Public entry point ─────────────────────────────────────────────────── + + pub async fn run(mut self) -> Result> { + enable_raw_mode()?; + let mut stdout = std::io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste)?; + // Enable Kitty keyboard protocol so Shift+Enter is distinguishable from Enter. + // Silently ignore on terminals that don't support it. + let _ = execute!( + stdout, + PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) + ); + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Kick off background schema cache refresh. + // spawn_schema_retry_loop uses cache.refresh() directly as the readiness + // probe — it only succeeds once all schema queries execute without errors, + // handling both network failures and "Cluster not yet healthy" responses. + if !self.context.args.no_completion { + // Mark ping_active so drain_bg_output won't spawn a duplicate loop + // when ConnectionStatus(false) arrives from this same task. + self.ping_active = true; + self.spawn_schema_retry_loop(true); + } + + let result = self.event_loop(&mut terminal).await; + + disable_raw_mode()?; + let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags); + execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture, DisableBracketedPaste)?; + + result + } + + // ── Event loop ─────────────────────────────────────────────────────────── + + async fn event_loop( + &mut self, + terminal: &mut Terminal>, + ) -> Result> { + loop { + // ── Launch csvlens / $EDITOR here, before event::poll, so crossterm's + // global reader is fully idle (not inside any event handler). + if self.pending_viewer { + self.pending_viewer = false; + self.run_viewer(terminal); + } + if self.pending_editor { + self.pending_editor = false; + self.run_editor(terminal); + } + + self.drain_query_output(); + self.drain_bg_output(); + + if self.is_running { + self.spinner_tick += 1; + } + + if self.needs_clear { + terminal.clear()?; + self.needs_clear = false; + } + + terminal.draw(|f| self.render(f))?; + + // Poll with a short timeout so the spinner animates even without input. + // Drain ALL immediately-available events before the next render so that + // pasting N characters costs 1 render instead of N. + if event::poll(Duration::from_millis(100))? { + let mut needs_hint_update = false; + loop { + match event::read()? { + Event::Key(key) => { + if self.handle_key(key).await { + self.should_quit = true; + } + needs_hint_update = true; + } + // Bracketed paste: the terminal bundles the entire pasted + // string into one event — insert it all before re-rendering. + Event::Paste(text) => { + self.handle_paste(&text); + needs_hint_update = true; + } + Event::Mouse(mouse) => match mouse.kind { + MouseEventKind::ScrollUp => self.output.scroll_up(8), + MouseEventKind::ScrollDown => self.output.scroll_down(8), + MouseEventKind::Down(crossterm::event::MouseButton::Left) => { + self.handle_mouse_click(mouse.column, mouse.row); + } + _ => {} + }, + Event::Resize(_, _) => {} // redraw on next tick + _ => {} + } + // Stop draining if we must quit or no more events are ready. + if self.should_quit || !event::poll(Duration::from_millis(0))? { + break; + } + } + if needs_hint_update { + self.update_signature_hint(); + } + } + + if self.should_quit { + break; + } + } + + Ok(self.has_error) + } + + // ── Query output draining ──────────────────────────────────────────────── + + fn drain_query_output(&mut self) { + let rx = match &mut self.query_rx { + Some(r) => r, + None => return, + }; + + loop { + match rx.try_recv() { + Ok(TuiMsg::RunHint(hint)) => { + self.running_hint = hint; + } + Ok(TuiMsg::Progress(n)) => { + self.progress_rows = n; + } + Ok(TuiMsg::ParsedResult(result)) => { + if result.columns.is_empty() { + self.pending_schema_refresh = true; + } + self.context.last_result = Some(result); + } + Ok(TuiMsg::ParamUpdate(extras)) => { + if let Some(db) = extras.iter().find_map(|e| e.strip_prefix("database=")) { + self.context.args.database = db.to_string(); + } + self.context.args.extra = extras; + self.context.update_url(); + } + Ok(TuiMsg::StyledLines(lines)) => { + self.output.push_tui_lines(lines); + } + Ok(TuiMsg::ConnectionStatus(_)) => { + // ConnectionStatus is handled by drain_bg_output; shouldn't + // arrive on the query channel, but ignore gracefully. + } + Ok(TuiMsg::Line(line)) => { + // Capture the "Showing first N rows..." hint for the running pane only. + if self.is_running && line.starts_with("Showing first ") { + self.running_hint = line; + continue; + } + if line.starts_with("Time: ") || line.starts_with("Scanned: ") { + self.output.push_stat(&line); + } else if line.starts_with("Error: ") || line.starts_with("^C") { + self.output.push_error(&line); + } else { + self.output.push_ansi_text(&line); + } + } + Err(mpsc::error::TryRecvError::Empty) => break, + Err(mpsc::error::TryRecvError::Disconnected) => { + // Query task finished + self.is_running = false; + self.running_hint.clear(); + self.cancel_token = None; + self.query_rx = None; + + // A zero-column result signals likely DDL — refresh the + // schema cache so new/dropped tables appear in completion. + // The flag is only set on success (ParsedResult is only + // emitted when the query completed without errors). + if self.pending_schema_refresh && !self.context.args.no_completion { + self.pending_schema_refresh = false; + let cache = self.schema_cache.clone(); + let mut ctx_clone = self.context.without_transaction(); + tokio::spawn(async move { + let ok = cache.refresh(&mut ctx_clone).await.is_ok(); + if let Some(tx) = &ctx_clone.tui_output_tx { + let _ = tx.send(TuiMsg::ConnectionStatus(ok)); + } + }); + } + + break; + } + } + } + } + + // ── Background output draining ─────────────────────────────────────────── + + /// Drain the persistent background channel (schema refresh warnings, connection + /// status updates). Called every event-loop tick regardless of query state. + fn drain_bg_output(&mut self) { + loop { + match self.bg_rx.try_recv() { + Ok(TuiMsg::ConnectionStatus(ok)) => { + if ok { + let was_disconnected = !self.connected; + self.connected = true; + self.ping_active = false; + if was_disconnected { + // Schema was already refreshed by spawn_schema_retry_loop. + self.output.push_line("Reconnected."); + } + } else { + self.connected = false; + if !self.ping_active && !self.context.args.no_completion { + self.ping_active = true; + self.spawn_schema_retry_loop(false); + } + } + } + Ok(TuiMsg::Line(line)) => { + if line.starts_with("Warning:") || line.starts_with("Error:") { + self.output.push_error(&line); + } else { + self.output.push_ansi_text(&line); + } + } + Ok(TuiMsg::StyledLines(lines)) => { + self.output.push_tui_lines(lines); + } + Ok(_) => {} // other msg types not expected on bg channel + Err(mpsc::error::TryRecvError::Empty) => break, + Err(mpsc::error::TryRecvError::Disconnected) => break, + } + } + } + + /// Spawn a background task that calls `cache.refresh()` until it succeeds, + /// then sends `ConnectionStatus(true)`. Uses `cache.refresh()` directly as + /// the readiness probe — it returns `Err` for both network failures and + /// "Cluster not yet healthy" error responses, so we only signal success once + /// all schema queries have actually executed without errors. + /// + /// `first_attempt`: when `true` the first call is made immediately (no sleep); + /// when `false` (reconnect path) the first call is delayed by 1 s. + fn spawn_schema_retry_loop(&self, first_attempt: bool) { + let bg_tx = match &self.context.tui_output_tx { + Some(tx) => tx.clone(), + None => return, + }; + let cache = self.schema_cache.clone(); + let mut ctx = self.context.without_transaction(); + // Use a /dev/null channel so repeated retry warnings (connection refused, + // not-yet-healthy, etc.) don't flood the output pane every second. + let (devnull_tx, _devnull_rx) = mpsc::unbounded_channel::(); + ctx.tui_output_tx = Some(devnull_tx); + + tokio::spawn(async move { + let mut is_first = first_attempt; + let mut sent_disconnected = false; + loop { + if !is_first { + tokio::time::sleep(Duration::from_secs(1)).await; + } + is_first = false; + + let cache_populated = match cache.refresh(&mut ctx).await { + Ok(()) => { + !cache.get_all_functions().is_empty() + || !cache.get_all_tables().is_empty() + } + Err(_) => false, + }; + if cache_populated { + let _ = bg_tx.send(TuiMsg::ConnectionStatus(true)); + return; + } + if !sent_disconnected { + let _ = bg_tx.send(TuiMsg::ConnectionStatus(false)); + sent_disconnected = true; + } + } + }); + } + + // ── Key handling ───────────────────────────────────────────────────────── + + /// Returns `true` when the app should exit. + async fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> bool { + // ── Ctrl+H help overlay ────────────────────────────────────────────── + // Handled before everything else so it works during queries too. + if key.code == KeyCode::Char('h') && key.modifiers.contains(KeyModifiers::CONTROL) { + self.help_visible = !self.help_visible; + return false; + } + // Escape or q closes the help overlay (and nothing else). + if self.help_visible + && (key.code == KeyCode::Esc + || key.code == KeyCode::Char('q')) + { + self.help_visible = false; + return false; + } + // While the overlay is open, swallow all other keys. + if self.help_visible { + return false; + } + + // While a query is running only Ctrl+C is accepted (to cancel) + if self.is_running { + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + if let Some(token) = &self.cancel_token { + token.cancel(); + } + } + return false; + } + + // ── Ctrl+R history search mode ──────────────────────────────────────── + if self.history_search.is_some() { + return self.handle_history_search_key(key).await; + } + + // ── Completion popup navigation ─────────────────────────────────────── + if self.completion_state.is_some() { + return self.handle_completion_key(key).await; + } + + // ── Fuzzy search overlay ────────────────────────────────────────────── + if self.fuzzy_state.is_some() { + return self.handle_fuzzy_key(key).await; + } + + match (key.code, key.modifiers) { + // ── Exit ────────────────────────────────────────────────────── + (KeyCode::Char('d'), m) if m.contains(KeyModifiers::CONTROL) => { + self.should_quit = true; + return true; + } + + // ── Ctrl+R: start reverse history search ───────────────────── + (KeyCode::Char('r'), m) if m.contains(KeyModifiers::CONTROL) => { + let saved = self.textarea.lines().join("\n"); + let entries = self.history.entries().to_vec(); + self.history_search = Some(HistorySearch::new(saved, &entries)); + // Immediately preview the most recent match + self.preview_history_match(&entries); + } + + // ── Cancel / clear ──────────────────────────────────────────── + (KeyCode::Char('c'), m) if m.contains(KeyModifiers::CONTROL) => { + self.completion_state = None; + self.reset_textarea(); + self.history.reset_navigation(); + } + + // ── Ctrl+V: open csvlens ────────────────────────────────────── + (KeyCode::Char('v'), m) if m.contains(KeyModifiers::CONTROL) => { + self.open_viewer(); + } + + // ── Alt+E: open in $EDITOR ──────────────────────────────────── + (KeyCode::Char('e'), m) if m.contains(KeyModifiers::ALT) => { + self.pending_editor = true; + } + + // ── Ctrl+Space: open fuzzy schema search ───────────────────── + (KeyCode::Char(' '), m) if m.contains(KeyModifiers::CONTROL) => { + self.open_fuzzy_search(); + } + + // ── Alt+F: format SQL ───────────────────────────────────────── + (KeyCode::Char('f'), m) if m.contains(KeyModifiers::ALT) => { + self.format_sql(); + } + + // ── Tab: trigger / navigate completion ─────────────────────── + (KeyCode::Tab, _) => { + self.trigger_or_advance_completion(); + } + + // ── Shift+Tab: navigate completion backwards ────────────────── + (KeyCode::BackTab, _) => { + if let Some(cs) = &mut self.completion_state { + cs.prev(); + } + } + + // ── Shift+Enter / Alt+Enter: always insert newline ─────────── + (KeyCode::Enter, m) + if m.contains(KeyModifiers::SHIFT) || m.contains(KeyModifiers::ALT) => + { + self.textarea.insert_newline(); + } + + // ── Submit on Enter ─────────────────────────────────────────── + (KeyCode::Enter, m) if !m.contains(KeyModifiers::ALT) => { + let text = self.textarea.lines().join("\n"); + let trimmed = text.trim().to_string(); + + if trimmed.is_empty() { + return false; + } + + // Slash meta-commands + if trimmed.starts_with('/') { + self.handle_slash_command(&trimmed).await; + self.reset_textarea(); + return false; + } + + // Dot client-side commands: .format = ..., .completion = on|off + if trimmed.starts_with('.') { + self.history.add(trimmed.clone()); + self.output.push_line(trimmed.clone()); + // Attach a temporary channel so dot_command output goes to the + // output pane instead of eprintln! (which would corrupt the TUI). + let (tx, mut rx) = mpsc::unbounded_channel::(); + self.context.tui_output_tx = Some(tx); + dot_command(&mut self.context, &trimmed); + self.context.tui_output_tx = None; + while let Ok(TuiMsg::Line(line)) = rx.try_recv() { + if line.starts_with("Error: ") { + self.output.push_error(&line); + } else { + self.output.push_ansi_text(&line); + } + } + self.completer.set_enabled(!self.context.args.no_completion); + self.reset_textarea(); + return false; + } + + if trimmed == "quit" || trimmed == "exit" { + self.should_quit = true; + return true; + } + + if let Some(queries) = try_split_queries(&trimmed) { + self.history.add(trimmed.clone()); + self.history.reset_navigation(); + self.execute_queries(trimmed, queries).await; + self.reset_textarea(); + } else { + // Query not complete yet – add newline + self.textarea.insert_newline(); + } + } + + // ── History: Up/Down at buffer boundaries; Ctrl+Up/Down always ── + (KeyCode::Up, m) if m.contains(KeyModifiers::CONTROL) => { + let current = self.textarea.lines().join("\n"); + if let Some(entry) = self.history.go_back(¤t) { + self.set_textarea_content(&entry); + } + } + (KeyCode::Down, m) if m.contains(KeyModifiers::CONTROL) => { + if let Some(entry) = self.history.go_forward() { + self.set_textarea_content(&entry); + self.textarea.move_cursor(CursorMove::Top); + self.textarea.move_cursor(CursorMove::Head); + } + } + (KeyCode::Up, _) => { + let (row, _) = self.textarea.cursor(); + if row == 0 { + let current = self.textarea.lines().join("\n"); + if let Some(entry) = self.history.go_back(¤t) { + self.set_textarea_content(&entry); + } + } else { + // Move cursor within the buffer; keep history navigation active so + // pressing Up again after reaching row 0 continues going back. + self.textarea.input(Input::from(key)); + } + } + (KeyCode::Down, _) => { + let (row, _) = self.textarea.cursor(); + let last_row = self.textarea.lines().len().saturating_sub(1); + if row >= last_row { + if let Some(entry) = self.history.go_forward() { + self.set_textarea_content(&entry); + self.textarea.move_cursor(CursorMove::Top); + self.textarea.move_cursor(CursorMove::Head); + } + } else { + // Move cursor within the buffer; keep history navigation active. + self.textarea.input(Input::from(key)); + } + } + + // ── Scroll output pane ──────────────────────────────────────── + (KeyCode::PageUp, _) => { + self.output.scroll_up(10); + } + (KeyCode::PageDown, _) => { + self.output.scroll_down(10); + } + + // ── Undo / Redo ─────────────────────────────────────────────── + (KeyCode::Char('z'), m) if m.contains(KeyModifiers::CONTROL) => { + self.textarea.undo(); + self.completion_state = None; + } + (KeyCode::Char('y'), m) if m.contains(KeyModifiers::CONTROL) => { + self.textarea.redo(); + self.completion_state = None; + } + + // ── All other keys → textarea ───────────────────────────────── + _ => { + let input = Input::from(key); + self.textarea.input(input); + self.history.reset_navigation(); + } + } + + false + } + + // ── Ctrl+R history search ──────────────────────────────────────────────── + + /// Key handler active while `history_search` is `Some`. + /// Returns `true` if the app should quit (never, but matches signature). + async fn handle_history_search_key(&mut self, key: crossterm::event::KeyEvent) -> bool { + let entries = self.history.entries().to_vec(); + + // Compute the visible list height for scroll tracking (approximation) + let list_h = history_search::MAX_VISIBLE; + + match (key.code, key.modifiers) { + // Ctrl+R or Up arrow: cycle to next older match + (KeyCode::Char('r'), m) if m.contains(KeyModifiers::CONTROL) => { + if let Some(hs) = &mut self.history_search { + hs.search_older(&entries, list_h); + } + self.preview_history_match(&entries); + } + (KeyCode::Up, _) => { + if let Some(hs) = &mut self.history_search { + hs.select_older(list_h); + } + self.preview_history_match(&entries); + } + + // Down arrow: cycle to next newer match + (KeyCode::Down, _) => { + if let Some(hs) = &mut self.history_search { + hs.select_newer(list_h); + } + self.preview_history_match(&entries); + } + + // Escape or Ctrl+G: cancel, restore saved content + (KeyCode::Esc, _) + | (KeyCode::Char('g'), crossterm::event::KeyModifiers::CONTROL) => { + let saved = self + .history_search + .take() + .map(|hs| hs.saved_content().to_string()) + .unwrap_or_default(); + self.set_textarea_content(&saved); + } + + // Ctrl+C: cancel and clear + (KeyCode::Char('c'), m) if m.contains(KeyModifiers::CONTROL) => { + self.history_search = None; + self.reset_textarea(); + } + + // Enter: accept — textarea already shows the preview + (KeyCode::Enter, _) => { + self.history_search = None; + // textarea already has the previewed content; nothing else to do + } + + // Backspace: remove one character from the query + (KeyCode::Backspace, _) => { + if let Some(hs) = &mut self.history_search { + hs.pop_char(&entries); + } + self.preview_history_match(&entries); + } + + // Ctrl+A: move cursor to beginning of search query + (KeyCode::Char('a'), m) if m.contains(KeyModifiers::CONTROL) => { + if let Some(hs) = &mut self.history_search { + hs.cursor_to_start(); + } + } + + // Left / Right: navigate cursor within the search query + (KeyCode::Left, _) => { + if let Some(hs) = &mut self.history_search { + hs.move_cursor_left(); + } + } + (KeyCode::Right, _) => { + if let Some(hs) = &mut self.history_search { + hs.move_cursor_right(); + } + } + + // Printable character: append to search query + (KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => { + if let Some(hs) = &mut self.history_search { + hs.push_char(c, &entries); + } + self.preview_history_match(&entries); + } + + // Any other key: accept match, exit search, re-process key normally + _ => { + self.history_search = None; + // textarea already has the previewed content + return Box::pin(self.handle_key(key)).await; + } + } + + false + } + + /// Update the textarea to show a preview of the currently selected history match. + /// On no match, restores the saved content. + fn preview_history_match(&mut self, entries: &[String]) { + let content = self + .history_search + .as_ref() + .and_then(|hs| hs.matched(entries)) + .map(|s| s.to_string()) + .or_else(|| { + self.history_search + .as_ref() + .map(|hs| hs.saved_content().to_string()) + }) + .unwrap_or_default(); + self.set_textarea_content(&content); + } + + // ── Signature hint ─────────────────────────────────────────────────────── + + /// Recompute which function (if any) the cursor is currently inside and + /// update `self.signature_hint` accordingly. + fn update_signature_hint(&mut self) { + // Don't show during overlays or while a query is running + if self.is_running + || self.history_search.is_some() + || self.fuzzy_state.is_some() + || self.help_visible + || self.context.args.no_completion + { + self.signature_hint = None; + return; + } + + let lines = self.textarea.lines(); + let (cursor_row, cursor_col) = self.textarea.cursor(); + // Compute cursor byte offset in the joined SQL + let byte_offset: usize = lines[..cursor_row] + .iter() + .map(|l| l.len() + 1) // +1 for \n + .sum::() + + cursor_col; + let full_sql = lines.join("\n"); + + match signature_hint::detect_function_at_cursor(&full_sql, byte_offset) { + Some(func_name) => { + let sigs = self.schema_cache.get_signatures(&func_name); + if sigs.is_empty() { + self.signature_hint = None; + } else { + self.signature_hint = Some((func_name, sigs)); + } + } + None => { + self.signature_hint = None; + } + } + } + + // ── Tab completion ─────────────────────────────────────────────────────── + + /// Called on Tab when no popup is open: computes candidates and opens the popup. + /// If there is exactly one candidate, accepts it immediately. + /// If the popup is already open, advances to the next item. + fn trigger_or_advance_completion(&mut self) { + if let Some(cs) = &mut self.completion_state { + cs.next(); + return; + } + + // ── Slash-command completion ────────────────────────────────────────── + { + use crate::completion::{CompletionItem, usage_tracker::ItemType}; + let (cursor_row, cursor_col) = self.textarea.cursor(); + // Owned copy so we can borrow self.textarea mutably later. + let first_line: String = self.textarea.lines().first().cloned().unwrap_or_default(); + + if cursor_row == 0 && first_line.starts_with('/') { + let line_to_cursor = &first_line[..cursor_col.min(first_line.len())]; + + // Case A: no space before cursor → complete the command name itself. + if !line_to_cursor.contains(' ') { + let all_cmds: &[(&str, &str)] = &[ + ("/benchmark", "cmd"), + ("/exit", "cmd"), + ("/qh", "cmd"), + ("/refresh", "cmd"), + ("/run", "cmd"), + ("/view", "cmd"), + ("/watch", "cmd"), + ]; + let items: Vec = all_cmds.iter() + .filter(|(c, _)| c.starts_with(line_to_cursor)) + .map(|(c, d)| CompletionItem { + value: format!("{} ", c), + description: d.to_string(), + item_type: ItemType::Table, + }) + .collect(); + if !items.is_empty() { + let common_prefix_len = { + let first = &items[0].value; + items.iter().skip(1).fold(first.len(), |len, item| { + first[..len].bytes().zip(item.value.bytes()) + .take_while(|(a, b)| a == b).count() + }) + }; + if common_prefix_len > cursor_col || items.len() == 1 { + let value = items[0].value[..common_prefix_len].to_string(); + self.textarea.move_cursor(CursorMove::Jump(0, 0)); + self.textarea.delete_str(cursor_col); + self.textarea.insert_str(&value); + } else { + let cs = CompletionState::new(items, 0, 0, 0, false); + self.completion_state = Some(cs); + } + } + return; + } + + // Case B: space found → @file argument completion for /run, /benchmark, /watch. + if let Some(arg_col) = find_slash_arg_col(&first_line) { + if cursor_col >= arg_col { + let partial = &first_line[arg_col..cursor_col.min(first_line.len())]; + + if partial.starts_with('@') { + // File path completion after the '@' prefix. + // Support optional opening quote: @"path or @'path + let file_partial = &partial[1..]; // everything after '@' + let (quote, path_partial) = + if file_partial.starts_with('"') { + (Some('"'), &file_partial[1..]) + } else if file_partial.starts_with('\'') { + (Some('\''), &file_partial[1..]) + } else { + (None, file_partial) + }; + + let mut items = complete_file_paths(path_partial); + + // Wrap completion values in the same quote the user opened with. + // Files get a closing quote unless one already follows the cursor; + // directories never get a closing quote (allow continued navigation). + if let Some(q) = quote { + let closing_exists = first_line[cursor_col..].starts_with(q); + for item in &mut items { + if item.value.ends_with('/') || closing_exists { + item.value = format!("{}{}", q, item.value); + } else { + item.value = format!("{}{}{}", q, item.value, q); + } + } + } + + if !items.is_empty() { + let file_start_col = arg_col + 1; // right after '@' + let common_prefix_len = { + let first = &items[0].value; + items.iter().skip(1).fold(first.len(), |len, item| { + first[..len].bytes().zip(item.value.bytes()) + .take_while(|(a, b)| a == b).count() + }) + }; + if common_prefix_len > file_partial.len() || items.len() == 1 { + let value = items[0].value[..common_prefix_len].to_string(); + self.textarea.move_cursor(CursorMove::Jump(0, file_start_col as u16)); + self.textarea.delete_str(file_partial.len()); + self.textarea.insert_str(&value); + } else { + let cs = CompletionState::new( + items, file_start_col, file_start_col, 0, false, + ); + self.completion_state = Some(cs); + } + } + } else if partial.is_empty() { + // Suggest '@' to indicate file mode. + let items = vec![CompletionItem { + value: "@".to_string(), + description: "file".to_string(), + item_type: ItemType::Table, + }]; + let cs = CompletionState::new(items, arg_col, arg_col, 0, false); + self.completion_state = Some(cs); + } + return; + } + } + } + } + + // ── Dot-command completion ───────────────────────────────────────── + { + use crate::completion::{CompletionItem, usage_tracker::ItemType}; + let (cursor_row, cursor_col) = self.textarea.cursor(); + let first_line: String = self.textarea.lines().first().cloned().unwrap_or_default(); + + if cursor_row == 0 && first_line.trim_start().starts_with('.') { + let line_to_cursor = &first_line[..cursor_col.min(first_line.len())]; + + if let Some(eq_pos) = line_to_cursor.find('=') { + // Case B: after '=' → complete value + let key_part = line_to_cursor[..eq_pos].trim().trim_start_matches('.'); + let after_eq = &line_to_cursor[eq_pos + 1..]; + let leading_spaces = after_eq.len() - after_eq.trim_start().len(); + let value_col = eq_pos + 1 + leading_spaces; + let partial = &line_to_cursor[value_col..]; + + let candidates: &[(&str, &str)] = match key_part { + "format" => &[ + ("client:auto", "auto"), + ("client:vertical", "vertical"), + ("client:horizontal", "horizontal"), + ], + "completion" => &[ + ("on", "enable"), + ("off", "disable"), + ], + _ => &[], + }; + + let items: Vec = candidates.iter() + .filter(|(v, _)| v.to_lowercase().starts_with(&partial.to_lowercase())) + .map(|(v, d)| CompletionItem { + value: v.to_string(), + description: d.to_string(), + item_type: ItemType::Table, + }) + .collect(); + + if !items.is_empty() { + let common_prefix_len = { + let first = &items[0].value; + items.iter().skip(1).fold(first.len(), |len, item| { + first[..len].bytes().zip(item.value.bytes()) + .take_while(|(a, b)| a == b).count() + }) + }; + if common_prefix_len > partial.len() || items.len() == 1 { + let value = items[0].value[..common_prefix_len].to_string(); + self.textarea.move_cursor(CursorMove::Jump(0, value_col as u16)); + self.textarea.delete_str(partial.len()); + self.textarea.insert_str(&value); + } else { + let cs = CompletionState::new(items, value_col, value_col, 0, false); + self.completion_state = Some(cs); + } + } + return; + } + + // Case A: no '=' yet → complete command name + let items: Vec = [ + (".format = ", "output format"), + (".completion = ", "tab completion"), + ] + .iter() + .filter(|(c, _)| c.starts_with(line_to_cursor)) + .map(|(c, d)| CompletionItem { + value: c.to_string(), + description: d.to_string(), + item_type: ItemType::Table, + }) + .collect(); + + if !items.is_empty() { + let common_prefix_len = { + let first = &items[0].value; + items.iter().skip(1).fold(first.len(), |len, item| { + first[..len].bytes().zip(item.value.bytes()) + .take_while(|(a, b)| a == b).count() + }) + }; + if common_prefix_len > cursor_col || items.len() == 1 { + let value = items[0].value[..common_prefix_len].to_string(); + self.textarea.move_cursor(CursorMove::Jump(0, 0)); + self.textarea.delete_str(cursor_col); + self.textarea.insert_str(&value); + } else { + let cs = CompletionState::new(items, 0, 0, 0, false); + self.completion_state = Some(cs); + } + } + return; + } + } + + // Compute current cursor position in terms of the full content + let (cursor_row, cursor_col) = self.textarea.cursor(); + let lines = self.textarea.lines(); + // byte offset of cursor in the full joined text + let byte_offset: usize = lines[..cursor_row].iter().map(|l| l.len() + 1).sum::() + + cursor_col; + // current line up to cursor for completion + let line_to_cursor = &lines[cursor_row][..cursor_col]; + let full_sql = lines.join("\n"); + + let (word_start_byte_in_line, mut items) = + self.completer.complete_at(line_to_cursor, cursor_col, &full_sql); + + // ── Named-parameter completions ─────────────────────────────────────── + // When the cursor is inside a function with known signatures, inject + // `param_name => ` suggestions right after any in-query column items. + { + use crate::completion::{CompletionItem, usage_tracker::ItemType}; + + if let Some((_, sigs)) = &self.signature_hint { + let partial = &line_to_cursor[word_start_byte_in_line..cursor_col]; + let partial_lower = partial.to_lowercase(); + + // Collect unique param names across all overloads, in order. + // A token is a name (not a type) when its first char is lowercase + // (types are stored UPPERCASE in signature strings). + let mut seen = std::collections::HashSet::new(); + let mut param_items: Vec = Vec::new(); + + for sig in sigs { + let open = sig.find('(').unwrap_or(sig.len()); + let inner = sig[open + 1..].trim_end_matches(')'); + for param in inner.split(", ") { + // Strip flow-wrap trailing comma and " => default" + let main = param.trim().trim_end_matches(','); + let main = main.split(" => ").next().unwrap_or(main); + if main.chars().next().map(|c| c.is_lowercase()).unwrap_or(false) { + let name = main.split_whitespace().next().unwrap_or(""); + // Strip trailing "..." from variadic names like "val..." + let name = name.trim_end_matches("..."); + if !name.is_empty() + && seen.insert(name.to_owned()) + && name.starts_with(&partial_lower) + { + param_items.push(CompletionItem { + value: format!("{} => ", name), + description: "param".to_string(), + item_type: ItemType::Column, + }); + } + } + } + } + + if !param_items.is_empty() { + // Insert after column items, before tables/functions. + let col_end = items + .iter() + .position(|i| i.description != "column") + .unwrap_or(items.len()); + let tail = items.split_off(col_end); + items.extend(param_items); + items.extend(tail); + } + } + } + + if items.is_empty() { + return; + } + + // If the character immediately after the cursor is already `(`, strip the + // trailing `(` from function completion inserts so we don't double up. + let next_char_is_paren = lines + .get(cursor_row) + .and_then(|line| line[cursor_col..].chars().next()) + .map(|c| c == '(') + .unwrap_or(false); + if next_char_is_paren { + for item in &mut items { + if item.value.ends_with('(') { + item.value.pop(); + } + } + } + + // word_start as byte offset in the FULL text (for deletion later) + let word_start_byte = + byte_offset - (cursor_col - word_start_byte_in_line); + + // Find the longest common prefix of all item values. + let common_prefix_len = { + let first = &items[0].value; + items.iter().skip(1).fold(first.len(), |len, item| { + first[..len] + .bytes() + .zip(item.value.bytes()) + .take_while(|(a, b)| a == b) + .count() + }) + }; + let partial_len = cursor_col - word_start_byte_in_line; + + // If all items share a prefix longer than what's already typed, complete + // to that prefix immediately (like a single-item completion). The user + // can press Tab again to open the popup for the remaining choices. + if common_prefix_len > partial_len || items.len() == 1 { + let value = items[0].value[..common_prefix_len].to_string(); + let single = items.len() == 1; + // Jump to word start, then delete the partial word forward, then insert. + // (delete_str deletes FORWARD from cursor, so we must reposition first.) + self.textarea.move_cursor(CursorMove::Jump(cursor_row as u16, word_start_byte_in_line as u16)); + self.textarea.delete_str(partial_len); + self.textarea.insert_str(&value); + // Only advance past an existing `(` when the completion is unambiguous. + if next_char_is_paren && single { + self.textarea.move_cursor(CursorMove::Forward); + } + return; + } + + let cs = CompletionState::new(items, word_start_byte, word_start_byte_in_line, cursor_row, next_char_is_paren); + self.completion_state = Some(cs); + } + + /// Accept the currently selected completion item, replace the partial word, close popup. + fn accept_completion(&mut self) { + let cs = match self.completion_state.take() { + Some(c) => c, + None => return, + }; + + let selected = match cs.selected_item() { + Some(item) => item.value.clone(), + None => return, + }; + + let (cursor_row, cursor_col) = self.textarea.cursor(); + let partial_len = cursor_col - cs.word_start_col; + let advance = cs.advance_past_paren; + // Jump to word start, then delete the partial word forward, then insert. + // (delete_str deletes FORWARD from cursor, so we must reposition first.) + self.textarea.move_cursor(CursorMove::Jump(cursor_row as u16, cs.word_start_col as u16)); + self.textarea.delete_str(partial_len); + self.textarea.insert_str(&selected); + if advance { + self.textarea.move_cursor(CursorMove::Forward); + } + } + + /// Insert a pasted string into the textarea at the current cursor position. + /// Called for `Event::Paste` (bracketed-paste) events. + /// `\r` is skipped; `\n` becomes a newline; all other chars are inserted normally. + fn handle_paste(&mut self, text: &str) { + if self.is_running { + return; + } + self.completion_state = None; + for ch in text.chars() { + if ch == '\n' { + self.textarea.insert_newline(); + } else if ch != '\r' { + self.textarea.insert_char(ch); + } + } + } + + /// Handle a left-mouse-button click at terminal position `(col, row)`. + /// + /// Priority: + /// 1. Click inside the completion popup → select that item and accept. + /// 2. Click inside the textarea → move the cursor to the clicked position. + fn handle_mouse_click(&mut self, col: u16, row: u16) { + let pos = ratatui::layout::Position::new(col, row); + + // 1. Completion popup click. + if let Some(popup) = self.last_popup_rect { + if popup.contains(pos) { + // Border takes 1 row at the top; items start at popup.y + 1. + if row >= popup.y + 1 && row < popup.y + popup.height.saturating_sub(1) { + let item_idx = (row - popup.y - 1) as usize; + if let Some(cs) = &mut self.completion_state { + cs.select_at(item_idx + cs.scroll_offset); + } + self.accept_completion(); + } + return; + } + } + + // 2. Textarea click — move cursor to the clicked character. + if self.last_textarea_area.contains(pos) && !self.is_running { + // Map screen position to content position accounting for scroll. + let target_row = (row - self.last_textarea_area.y) as u16 + self.ta_row_top; + let target_col = (col - self.last_textarea_area.x) as u16 + self.ta_col_top; + self.textarea.move_cursor(CursorMove::Jump(target_row, target_col)); + self.completion_state = None; + } + } + + /// Key handler active while the completion popup is open. + /// Returns `true` only if the app should quit (never, but matches signature). + async fn handle_completion_key(&mut self, key: crossterm::event::KeyEvent) -> bool { + match (key.code, key.modifiers) { + // Tab / Down: next item + (KeyCode::Tab, _) | (KeyCode::Down, _) => { + if let Some(cs) = &mut self.completion_state { + cs.next(); + } + } + // Shift+Tab / Up: previous item + (KeyCode::BackTab, _) | (KeyCode::Up, _) => { + if let Some(cs) = &mut self.completion_state { + cs.prev(); + } + } + // Enter: accept selection (do NOT submit query) + (KeyCode::Enter, _) => { + self.accept_completion(); + } + // Escape: close without accepting + (KeyCode::Esc, _) => { + self.completion_state = None; + } + // Any other key: close popup and re-dispatch normally + _ => { + self.completion_state = None; + return Box::pin(self.handle_key(key)).await; + } + } + false + } + + // ── Fuzzy search ───────────────────────────────────────────────────────── + + /// Extract the tables referenced in the current textarea content so fuzzy + /// search can apply the same context-aware priority as tab completion. + fn current_sql_tables(&self) -> Vec { + let lines = self.textarea.lines(); + let sql = lines.join("\n"); + ContextAnalyzer::extract_tables(&sql) + } + + fn open_fuzzy_search(&mut self) { + let tables = self.current_sql_tables(); + let mut state = FuzzyState::new(); + state.items = self.fuzzy_completer.search("", 100, &tables); + self.fuzzy_state = Some(state); + } + + /// Key handler for the fuzzy popup. Returns true if app should quit. + async fn handle_fuzzy_key(&mut self, key: crossterm::event::KeyEvent) -> bool { + match (key.code, key.modifiers) { + // Escape or Ctrl+Space: close + (KeyCode::Esc, _) + | (KeyCode::Char(' '), crossterm::event::KeyModifiers::CONTROL) => { + self.fuzzy_state = None; + } + + // Enter: accept selected item + (KeyCode::Enter, _) => { + if let Some(fs) = &self.fuzzy_state { + if let Some(item) = fs.selected_item() { + let value = item.insert_value.clone(); + self.fuzzy_state = None; + self.textarea.insert_str(&value); + } else { + self.fuzzy_state = None; + } + } + } + + // Down / Tab: next item + (KeyCode::Down, _) | (KeyCode::Tab, _) => { + if let Some(fs) = &mut self.fuzzy_state { + fs.next(); + } + } + + // Up / Shift+Tab: previous item + (KeyCode::Up, _) | (KeyCode::BackTab, _) => { + if let Some(fs) = &mut self.fuzzy_state { + fs.prev(); + } + } + + // Backspace: delete last char from query and re-search + (KeyCode::Backspace, _) => { + if let Some(fs) = &mut self.fuzzy_state { + fs.pop_char(); + let q = fs.query.clone(); + let tables = self.current_sql_tables(); + let items = self.fuzzy_completer.search(&q, 100, &tables); + self.fuzzy_state.as_mut().unwrap().items = items; + } + } + + // Printable char: append to query and re-search + (KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => { + if let Some(fs) = &mut self.fuzzy_state { + fs.push_char(c); + let q = fs.query.clone(); + let tables = self.current_sql_tables(); + let items = self.fuzzy_completer.search(&q, 100, &tables); + self.fuzzy_state.as_mut().unwrap().items = items; + } + } + + // Any other key: close and re-dispatch + _ => { + self.fuzzy_state = None; + return Box::pin(self.handle_key(key)).await; + } + } + false + } + + // ── Slash commands ──────────────────────────────────────────────────────── + + async fn handle_slash_command(&mut self, cmd: &str) { + self.history.reset_navigation(); + + // /exit and /quit + if cmd == "/exit" || cmd == "/quit" { + self.should_quit = true; + return; + } + + // /run — accepts inline SQL or @ + if cmd.starts_with("/run") { + let arg = cmd["/run".len()..].trim(); + if arg.is_empty() { + self.output.push_line("Usage: /run @ or /run "); + return; + } + self.history.add(cmd.to_string()); + if let Some(path) = arg.strip_prefix('@') { + let path = strip_outer_quotes(path); + match read_file_content(path) { + Ok(content) => match crate::query::try_split_queries(&content) { + Some(queries) if !queries.is_empty() => { + self.output.push_line(format!("/run @{}", path)); + let preview = sql_preview(content.trim(), 5); + self.execute_queries(preview, queries).await; + } + _ => self.output.push_error("Error: no queries found in file"), + }, + Err(e) => self.output.push_error(&e), + } + } else { + match crate::query::try_split_queries(arg) { + Some(queries) if !queries.is_empty() => { + self.execute_queries(arg.to_string(), queries).await; + } + _ => self.output.push_error(&format!("Error: could not parse query: {}", arg)), + } + } + return; + } + + // /benchmark — accepts inline SQL or @ + if cmd.starts_with("/benchmark") { + self.history.add(cmd.to_string()); + let (n_runs, query_text) = parse_benchmark_args(cmd); + let resolved = if let Some(path) = query_text.strip_prefix('@') { + let path = strip_outer_quotes(path); + match read_file_content(path) { + Ok(c) => c.trim().to_string(), + Err(e) => { self.output.push_error(&e); return; } + } + } else { query_text }; + self.do_benchmark(n_runs, resolved).await; + return; + } + + // /watch — accepts inline SQL or @ + if cmd.starts_with("/watch") { + self.history.add(cmd.to_string()); + let (interval_secs, query_text) = parse_watch_args(cmd); + let resolved = if let Some(path) = query_text.strip_prefix('@') { + let path = strip_outer_quotes(path); + match read_file_content(path) { + Ok(c) => c.trim().to_string(), + Err(e) => { self.output.push_error(&e); return; } + } + } else { query_text }; + self.do_watch(interval_secs, resolved).await; + return; + } + + // Command aliases (e.g. /qh) + if let Some(expanded) = expand_command_alias(cmd) { + if let Some(queries) = crate::query::try_split_queries(&expanded) { + self.history.add(cmd.to_string()); + self.execute_queries(expanded, queries).await; + } else { + self.output.push_line(format!("Error: could not parse query expanded from '{}': {}", cmd, expanded)); + } + return; + } + + match cmd { + "/view" => { + self.history.add(cmd.to_string()); + self.open_viewer(); + } + "/refresh" | "/refresh_cache" => { + self.history.add(cmd.to_string()); + self.do_refresh(); + } + _ => { + match handle_meta_command(&mut self.context, cmd) { + Ok(true) => {} + Ok(false) => self + .output + .push_line(format!("Unknown command: {}", cmd)), + Err(e) => self.output.push_line(format!("Error: {}", e)), + } + self.history.add(cmd.to_string()); + } + } + } + + // ── Export ─────────────────────────────────────────────────────────────── + + + // ── Benchmark ──────────────────────────────────────────────────────────── + + /// Run `query_text` N+1 times (1 warmup), collect per-run elapsed times, + /// and display min/avg/p90/max statistics in the output pane. + /// + /// Spawns a background task and sets `is_running = true` so the spinner / + /// timer are visible and Ctrl+C works. Each run's result is streamed back + /// through the normal query channel as it completes. + async fn do_benchmark(&mut self, n_runs: usize, query_text: String) { + if query_text.trim().is_empty() { + self.output.push_line( + "Usage: /benchmark [N] (default N=3, first run is warmup)", + ); + return; + } + + let total = n_runs + 1; // 1 warmup + N timed + self.output.push_line(format!( + "Benchmarking ({} warmup + {} timed run{}):", + 1, + n_runs, + if n_runs == 1 { "" } else { "s" } + )); + self.push_sql_echo(query_text.trim()); + self.push_custom_settings(); + + // Set up channel + running state, exactly like execute_queries. + let (tx, rx) = mpsc::unbounded_channel::(); + let cancel_token = CancellationToken::new(); + self.query_rx = Some(rx); + self.cancel_token = Some(cancel_token.clone()); + self.is_running = true; + self.running_hint.clear(); + self.progress_rows = 0; + self.query_start = Some(Instant::now()); + + let query_text = query_text.trim().to_string(); + let mut ctx = self.context.clone(); + // Disable result cache for accurate timing + if !ctx.args.extra.iter().any(|e| e.starts_with("enable_result_cache=")) { + ctx.args.extra.push("enable_result_cache=false".to_string()); + ctx.update_url(); + } + + tokio::spawn(async move { + let mut times_ms: Vec = Vec::with_capacity(n_runs); + + for i in 0..total { + if cancel_token.is_cancelled() { + let _ = tx.send(TuiMsg::Line("Benchmark cancelled.".to_string())); + return; + } + + let label = if i == 0 { + "warmup".to_string() + } else { + format!("run {}/{}", i, n_runs) + }; + let _ = tx.send(TuiMsg::RunHint(format!(" {label}…"))); + + let queries = match crate::query::try_split_queries(&query_text) { + Some(q) => q, + None => { + let _ = tx.send(TuiMsg::Line("Error: could not parse query".to_string())); + return; + } + }; + + // Inner context: discard output, share the cancel token + let (run_tx, mut run_rx) = mpsc::unbounded_channel::(); + let mut run_ctx = ctx.clone(); + run_ctx.tui_output_tx = Some(run_tx); + run_ctx.query_cancel = Some(cancel_token.clone()); + + let start = Instant::now(); + let mut handle = tokio::spawn(async move { + for q in queries { + if crate::query::query(&mut run_ctx, q).await.is_err() { + return false; + } + } + true + }); + + // Wait for the run to complete, draining output and watching for Ctrl+C. + let ok = loop { + tokio::select! { + _ = cancel_token.cancelled() => { + handle.abort(); + let _ = tx.send(TuiMsg::Line("Benchmark cancelled.".to_string())); + return; + } + result = &mut handle => { + while run_rx.try_recv().is_ok() {} + break result.unwrap_or(false); + } + Some(_) = run_rx.recv() => { + // drain inner-run messages silently + } + } + }; + + let elapsed = start.elapsed().as_secs_f64() * 1000.0; + + if !ok { + let _ = tx.send(TuiMsg::Line("Error: query failed during benchmark".to_string())); + return; + } + + let line = if i == 0 { + format!(" warmup: {:.1}ms", elapsed) + } else { + format!(" run {}/{}: {:.1}ms", i, n_runs, elapsed) + }; + let _ = tx.send(TuiMsg::Line(line)); + if i > 0 { + times_ms.push(elapsed); + } + } + + if !times_ms.is_empty() { + let min = times_ms.iter().cloned().fold(f64::INFINITY, f64::min); + let max = times_ms.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + let avg = times_ms.iter().sum::() / times_ms.len() as f64; + let mut sorted = times_ms.clone(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let p90_idx = (sorted.len() as f64 * 0.9).ceil() as usize; + let p90 = sorted[p90_idx.saturating_sub(1).min(sorted.len() - 1)]; + let _ = tx.send(TuiMsg::Line(format!( + "Results: min={:.1}ms avg={:.1}ms p90={:.1}ms max={:.1}ms", + min, avg, p90, max + ))); + } + // `tx` dropped here → channel disconnects → is_running = false + }); + } + + /// Re-run `query_text` every `interval_secs` seconds until Ctrl+C. + async fn do_watch(&mut self, interval_secs: u64, query_text: String) { + if query_text.trim().is_empty() { + self.output.push_line( + "Usage: /watch [N] (default interval N=5 seconds)", + ); + return; + } + + self.output.push_line(format!( + "Watching every {}s — Ctrl+C to stop:", + interval_secs + )); + self.push_sql_echo(query_text.trim()); + self.push_custom_settings(); + + let (tx, rx) = mpsc::unbounded_channel::(); + let cancel_token = CancellationToken::new(); + self.query_rx = Some(rx); + self.cancel_token = Some(cancel_token.clone()); + self.is_running = true; + self.running_hint.clear(); + self.progress_rows = 0; + self.query_start = Some(Instant::now()); + + let query_text = query_text.trim().to_string(); + let ctx = self.context.clone(); + + tokio::spawn(async move { + let watch_start = Instant::now(); + let mut interval = + tokio::time::interval(Duration::from_secs(interval_secs)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut run_count = 0u64; + + loop { + // Wait for the next tick (first tick is immediate) + tokio::select! { + _ = cancel_token.cancelled() => { + let _ = tx.send(TuiMsg::Line("Watch stopped.".to_string())); + return; + } + _ = interval.tick() => {} + } + + run_count += 1; + let elapsed = watch_start.elapsed().as_secs(); + let _ = tx.send(TuiMsg::Line(format!( + "── watch run {} (+{}s) ──────────────────────────", + run_count, elapsed + ))); + + let queries = match crate::query::try_split_queries(&query_text) { + Some(q) if !q.is_empty() => q, + _ => { + let _ = tx.send(TuiMsg::Line("Error: could not parse query".to_string())); + return; + } + }; + + let (run_tx, mut run_rx) = mpsc::unbounded_channel::(); + let mut run_ctx = ctx.clone(); + run_ctx.tui_output_tx = Some(run_tx); + run_ctx.query_cancel = Some(cancel_token.clone()); + + let mut handle = tokio::spawn(async move { + for q in queries { + if crate::query::query(&mut run_ctx, q).await.is_err() { + return; + } + } + }); + + // Drain inner-run messages and watch for cancellation + loop { + tokio::select! { + _ = cancel_token.cancelled() => { + handle.abort(); + let _ = tx.send(TuiMsg::Line("Watch stopped.".to_string())); + return; + } + _ = &mut handle => { + // Forward any remaining buffered messages + while let Ok(msg) = run_rx.try_recv() { + let _ = tx.send(msg); + } + break; + } + Some(msg) = run_rx.recv() => { + let _ = tx.send(msg); + } + } + } + } + }); + } + + /// Called from handle_key: validates that data is available, then queues + /// the viewer to open at the top of the next event-loop iteration. + fn open_viewer(&mut self) { + if self.context.last_result.is_none() { + self.set_flash("No results to display — run a query first"); + return; + } + if !self.context.args.format.starts_with("client:") { + self.set_flash("Viewer requires client format — run: .format = client:auto"); + return; + } + self.pending_viewer = true; + } + + /// Tear down raw mode and alternate screen so an external program can use + /// the terminal. Must be paired with a call to `resume_tui`. + fn suspend_tui(terminal: &mut Terminal>) { + let _ = disable_raw_mode(); + let _ = execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags); + let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture, DisableBracketedPaste); + let _ = std::io::Write::flush(terminal.backend_mut()); + } + + /// Restore raw mode and alternate screen after an external program has + /// finished. Pairs with `suspend_tui`. + fn resume_tui(terminal: &mut Terminal>) { + let _ = execute!(terminal.backend_mut(), EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste); + let _ = execute!( + terminal.backend_mut(), + PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) + ); + let _ = enable_raw_mode(); + let _ = std::io::Write::flush(terminal.backend_mut()); + let _ = terminal.clear(); + } + + /// Actually launches csvlens. Called at the top of event_loop, outside any + /// event-handler, so crossterm's global InternalEventReader is fully idle. + fn run_viewer(&mut self, terminal: &mut Terminal>) { + Self::suspend_tui(terminal); + let result = open_csvlens_viewer(&self.context); + Self::resume_tui(terminal); + + if let Err(e) = result { + self.set_flash(format!("Viewer error: {}", e)); + } + } + + /// Open the current textarea content in `$VISUAL` / `$EDITOR` / `vi`. + /// Called at the top of event_loop so the terminal reader is idle. + fn run_editor(&mut self, terminal: &mut Terminal>) { + // Write current content to a temp file. + let pid = std::process::id(); + let tmp_path = format!("/tmp/fb_edit_{}.sql", pid); + let content = self.textarea.lines().join("\n"); + if let Err(e) = std::fs::write(&tmp_path, &content) { + self.set_flash(format!("Editor error: could not create temp file: {}", e)); + return; + } + + // Resolve editor command. + let editor_cmd = std::env::var("VISUAL") + .or_else(|_| std::env::var("EDITOR")) + .unwrap_or_else(|_| "vi".to_string()); + let mut parts = editor_cmd.split_whitespace(); + let bin = match parts.next() { + Some(b) => b.to_string(), + None => { + self.set_flash("Editor error: EDITOR variable is empty"); + let _ = std::fs::remove_file(&tmp_path); + return; + } + }; + let extra_args: Vec = parts.map(|s| s.to_string()).collect(); + + Self::suspend_tui(terminal); + + let status = std::process::Command::new(&bin) + .args(&extra_args) + .arg(&tmp_path) + .status(); + + Self::resume_tui(terminal); + + match status { + Ok(s) if s.success() => { + // Read the edited content back into the textarea. + match std::fs::read_to_string(&tmp_path) { + Ok(new_content) => { + let trimmed = new_content.trim_end_matches('\n').to_string(); + self.set_textarea_content(&trimmed); + } + Err(e) => { + self.set_flash(format!("Editor error: could not read file: {}", e)); + } + } + } + Ok(_) => {} // Editor exited with non-zero; keep original content + Err(e) => { + self.set_flash(format!("Editor error: could not launch '{}': {}", bin, e)); + } + } + + let _ = std::fs::remove_file(&tmp_path); + } + + /// Show a temporary error message in the status bar for ~2 seconds. + fn set_flash(&mut self, msg: impl Into) { + self.flash_message = Some((msg.into(), Instant::now())); + } + + fn do_refresh(&mut self) { + if self.context.args.no_completion { + self.output + .push_line("Auto-completion is disabled. Enable with: set completion = on;"); + return; + } + let cache = self.schema_cache.clone(); + let mut ctx_clone = self.context.without_transaction(); + self.output.push_line("Refreshing schema cache..."); + tokio::spawn(async move { + let ok = cache.refresh(&mut ctx_clone).await.is_ok(); + if let Some(tx) = &ctx_clone.tui_output_tx { + let _ = tx.send(TuiMsg::ConnectionStatus(ok)); + } + }); + } + + // ── Query execution ────────────────────────────────────────────────────── + + /// Echo SQL text to the output pane with the same syntax highlighting as the input pane. + /// The first line gets the `❯ ` prefix (green+bold); continuation lines get ` `. + fn push_sql_echo(&mut self, sql: &str) { + let all_spans = self.highlighter.highlight_to_spans(sql); + let mut byte_offset = 0usize; + let mut first_line = true; + for line_text in sql.lines() { + let line_start = byte_offset; + let line_end = byte_offset + line_text.len(); + // Collect spans for this line, clipped and re-offset to line-local coords + let line_spans: Vec<(std::ops::Range, ratatui::style::Style)> = all_spans + .iter() + .filter(|(r, _)| r.start < line_end && r.end > line_start) + .map(|(r, style)| { + let s = r.start.saturating_sub(line_start); + let e = (r.end - line_start).min(line_text.len()); + (s..e, *style) + }) + .collect(); + let prefix = if first_line { "❯ " } else { " " }; + self.output.push_prompt_highlighted(prefix, line_text, &line_spans); + first_line = false; + byte_offset += line_text.len() + 1; // +1 for '\n' + } + } + + /// Print custom settings (from /set commands) as a grey line after the SQL echo. + /// Skips URL-construction params that are never user-visible. + fn push_custom_settings(&mut self) { + let display_extras: Vec = self + .context + .args + .extra + .iter() + .filter(|e| { + !e.starts_with("database=") + && !e.starts_with("format=") + && !e.starts_with("query_label=") + && !e.starts_with("advanced_mode=") + && !e.starts_with("output_format=") + // Server-managed transaction params — never show to user + && !e.starts_with("transaction_id=") + && !e.starts_with("transaction_sequence_id=") + }) + .map(|e| url_decode_setting(e)) + .collect(); + if !display_extras.is_empty() { + self.output.push_stat(&format!(" Settings: {}", display_extras.join(", "))); + } + } + + async fn execute_queries(&mut self, original_text: String, queries: Vec) { + // Apply set/unset commands to self.context immediately. For set commands + // that change server-side parameters (i.e. modify args.extra), first validate + // them by sending SELECT 1 with the new settings; bail out on rejection. + for q in &queries { + let mut test_ctx = self.context.without_transaction(); + let extra_before = test_ctx.args.extra.clone(); + if set_args(&mut test_ctx, q).unwrap_or(false) { + if test_ctx.args.extra != extra_before { + // Server-side parameter: validate before applying + if let Err(e) = validate_setting(&mut test_ctx).await { + self.push_sql_echo(q.trim()); + self.output.push_error(&format!("Error: {e}")); + if let Some(hint) = dot_command_hint(q) { + self.output.push_error(&format!("Tip: {hint}")); + } + return; + } + } + let _ = set_args(&mut self.context, q); + } else { + let _ = unset_args(&mut self.context, q); + } + } + + // Echo query to output pane with syntax highlighting + self.push_sql_echo(original_text.trim()); + self.push_custom_settings(); + + let (tx, rx) = mpsc::unbounded_channel::(); + let cancel_token = CancellationToken::new(); + + self.query_rx = Some(rx); + self.cancel_token = Some(cancel_token.clone()); + self.is_running = true; + self.running_hint.clear(); + self.progress_rows = 0; + self.query_start = Some(Instant::now()); + self.pending_schema_refresh = false; + + // Build a context clone with the TUI output channel attached + let mut ctx = self.context.clone(); + ctx.tui_output_tx = Some(tx); + ctx.query_cancel = Some(cancel_token); + + // Sync completer enabled state + self.completer.set_enabled(!self.context.args.no_completion); + + tokio::spawn(async move { + for q in queries { + if query(&mut ctx, q).await.is_err() { + // Error message already sent through the channel by query() + return; + } + } + // `ctx` is dropped here → `tui_output_tx` is dropped → channel disconnected + }); + } + + // ── Textarea helpers ───────────────────────────────────────────────────── + + /// Format the current textarea content as SQL (Alt+F). + /// + /// For `/benchmark`, `/watch`, and `/run` only the SQL argument is + /// formatted; the command prefix (including any optional numeric argument) + /// is preserved verbatim. SQL on the next line after the command is + /// handled as well as SQL on the same line. + fn format_sql(&mut self) { + let full = self.textarea.lines().join("\n"); + if full.trim().is_empty() { + return; + } + + let first_line = self.textarea.lines().first().map(|s| s.as_str()).unwrap_or(""); + let has_next_line = self.textarea.lines().len() > 1; + + let (prefix, sql) = match slash_cmd_sql_offset(first_line, has_next_line) { + Some(offset) => (&full[..offset], &full[offset..]), + None => ("", full.as_str()), + }; + + let options = sqlformat::FormatOptions { + indent: sqlformat::Indent::Spaces(2), + uppercase: Some(true), + ..sqlformat::FormatOptions::default() + }; + let formatted_sql = sqlformat::format(sql, &sqlformat::QueryParams::None, &options); + let formatted = format!("{}{}", prefix, formatted_sql); + if formatted != full { + self.set_textarea_content(&formatted); + } + } + + fn reset_textarea(&mut self) { + self.textarea = Self::make_textarea(); + } + + fn set_textarea_content(&mut self, content: &str) { + let lines: Vec = content.lines().map(|l| l.to_string()).collect(); + let ta = TextArea::new(if lines.is_empty() { + vec![String::new()] + } else { + lines + }); + self.textarea = ta; + // Move cursor to end of content + self.textarea.move_cursor(CursorMove::Bottom); + self.textarea.move_cursor(CursorMove::End); + // The new TextArea starts with viewport at (0,0). Reset our mirror so + // apply_textarea_highlights doesn't use stale offsets from before the + // content was replaced (stale col_top causes misaligned highlighting). + self.ta_row_top = 0; + self.ta_col_top = 0; + } + + // ── Rendering ──────────────────────────────────────────────────────────── + + fn render(&mut self, f: &mut ratatui::Frame) { + let area = f.area(); + + // Only highlight the current line when there are multiple lines — on a + // single-line textarea the highlight adds noise with no benefit. + let cursor_line_bg = if self.textarea.lines().len() > 1 { + ratatui::style::Style::default().bg(ratatui::style::Color::Indexed(234)) + } else { + ratatui::style::Style::default() + }; + self.textarea.set_cursor_line_style(cursor_line_bg); + + let input_height = if self.is_running { + // Running pane: spinner row + hint row + top/bottom borders = 4 + 4u16 + } else { + // Input pane: textarea lines + 2 border rows, at least 3 + let ta_lines = self.textarea.lines().len() as u16; + (ta_lines + 2).clamp(3, area.height / 3) + }; + + let layout = compute_layout(area, input_height); + + // Clamp scroll so it stays in bounds (width needed for wrapped-line count) + self.output.clamp_scroll(layout.output.height, layout.output.width); + + // Output pane + self.output.render(layout.output, f.buffer_mut()); + + if self.is_running { + self.render_running_pane(f, layout.input); + } else { + // Input area: outer block provides top + bottom separator lines + let outer_block = Block::default() + .borders(Borders::TOP | Borders::BOTTOM) + .border_style(Style::default().fg(Color::DarkGray)); + let inner = outer_block.inner(layout.input); + f.render_widget(outer_block, layout.input); + + // Split inner area: prompt column + textarea + let chunks = Layout::horizontal([ + Constraint::Length(2), // "❯ " prompt + Constraint::Min(0), + ]) + .split(inner); + + let prompt = Paragraph::new("❯").style(Style::default().fg(Color::Green)); + f.render_widget(prompt, chunks[0]); + f.render_widget(&self.textarea, chunks[1]); + + // Mirror tui-textarea's internal viewport update so that + // apply_textarea_highlights can map character positions to correct + // screen coordinates even when the content is scrolled. + let (cursor_row, cursor_col) = self.textarea.cursor(); + self.ta_row_top = Self::next_scroll_top( + self.ta_row_top, cursor_row as u16, chunks[1].height, + ); + self.ta_col_top = Self::next_scroll_top( + self.ta_col_top, cursor_col as u16, chunks[1].width, + ); + + // Record textarea area for mouse-click hit testing. + self.last_textarea_area = chunks[1]; + + // Apply per-token syntax highlighting to the rendered textarea buffer + let textarea_area = chunks[1]; + self.apply_textarea_highlights(f.buffer_mut(), textarea_area); + + // Signature hint popup: rendered first (lower z-order) so the + // completion popup can paint on top of it. + let sig_hint_h = if self.history_search.is_none() && self.fuzzy_state.is_none() { + if let Some((func_name, sigs)) = &self.signature_hint { + let func_name = func_name.clone(); + let sigs = sigs.clone(); + Self::render_signature_hint(f, layout.input, area, &func_name, &sigs) + .map(|r| r.height) + .unwrap_or(0) + } else { + 0 + } + } else { + 0 + }; + + // Render completion popup above the signature hint (if both visible). + self.last_popup_rect = None; + if self.history_search.is_none() { + if let Some(cs) = &self.completion_state { + if !cs.is_empty() { + let popup_rect = completion_popup::popup_area( + cs, + layout.input, + chunks[1].x, + area, + sig_hint_h, + ); + completion_popup::render(cs, popup_rect, f); + // Record for mouse-click hit testing. + self.last_popup_rect = Some(popup_rect); + } + } + } + } + + // History search popup (above input area, like fuzzy popup) + if self.history_search.is_some() { + let popup_h = (history_search::MAX_VISIBLE as u16 + 4).min(layout.input.y); + if popup_h > 2 { + let popup_rect = Rect::new( + 0, + layout.input.y.saturating_sub(popup_h), + area.width, + popup_h, + ); + // Borrow separately to avoid simultaneous &self + &mut self + let hs = self.history_search.as_ref().unwrap(); + let entries: Vec = self.history.entries().to_vec(); + Self::render_history_search_popup(f, popup_rect, hs, &entries, &self.highlighter); + } + } + + // Fuzzy search overlay (rendered on top of everything) + if let Some(fs) = &self.fuzzy_state { + let fuzzy_rect = fuzzy_popup::popup_area(layout.input, area); + if fuzzy_rect.height > 2 { + fuzzy_popup::render(fs, fuzzy_rect, f); + } + } + + // Ctrl+H help overlay (topmost — rendered above all other content) + if self.help_visible { + self.render_help_popup(f, area); + } + + // Status bar + self.render_status_bar(f, layout.status); + } + + /// Replicate tui-textarea's internal scroll-top calculation. + /// Given the previous top row/col and the current cursor position, returns + /// the new top row/col that keeps the cursor inside the visible area. + fn next_scroll_top(prev_top: u16, cursor: u16, len: u16) -> u16 { + if cursor < prev_top { + cursor + } else if len > 0 && prev_top + len <= cursor { + cursor + 1 - len + } else { + prev_top + } + } + + /// Apply syntax highlighting to the textarea by post-processing the ratatui buffer. + /// + /// After `tui-textarea` renders its content (cursor, selection, etc.) we walk + /// through the buffer cells corresponding to the textarea's screen area and + /// patch the foreground colour of "plain" text cells. We skip the cursor + /// character so the block-cursor stays visible. + fn apply_textarea_highlights( + &self, + buf: &mut ratatui::buffer::Buffer, + area: ratatui::layout::Rect, + ) { + use ratatui::style::Modifier; + + let lines = self.textarea.lines(); + let full_text = lines.join("\n"); + let spans = self.highlighter.highlight_to_spans(&full_text); + + let (cursor_row, cursor_col) = self.textarea.cursor(); + + // Compute the byte offset of the cursor character in `full_text`. + let cursor_byte = { + let mut off = 0usize; + for (i, line) in lines.iter().enumerate() { + if i == cursor_row { + off += line + .char_indices() + .nth(cursor_col) + .map(|(b, _)| b) + .unwrap_or(line.len()); + break; + } + off += line.len() + 1; // +1 for '\n' + } + off + }; + + // Find matching bracket for the character under the cursor, if any. + let paren_match_byte = find_matching_paren(&full_text, cursor_byte); + + // Highlight style applied to the matching bracket. + let paren_style = Style::default() + .fg(Color::LightCyan) + .bg(Color::Indexed(234)) // same as current-line background + .add_modifier(Modifier::BOLD); + + if spans.is_empty() && paren_match_byte.is_none() { + return; + } + + let row_scroll = self.ta_row_top as usize; + let col_scroll = self.ta_col_top as usize; + + let mut byte_offset = 0usize; + for (line_idx, line) in lines.iter().enumerate() { + // Rows scrolled above the visible area are not rendered. + if line_idx < row_scroll { + byte_offset += line.len() + 1; + continue; + } + + let visible_row = line_idx - row_scroll; + let screen_y = area.y + visible_row as u16; + if screen_y >= area.y + area.height { + break; + } + + let mut char_col = 0usize; + for (byte_in_line, _ch) in line.char_indices() { + let byte_pos = byte_offset + byte_in_line; + + // Characters scrolled to the left of the visible area are not rendered. + if char_col < col_scroll { + char_col += 1; + continue; + } + + let visible_col = char_col - col_scroll; + let screen_x = area.x + visible_col as u16; + if screen_x >= area.x + area.width { + break; + } + + // Skip the cursor character — it has special styling (REVERSED) + // that we want to preserve exactly as tui-textarea rendered it. + if line_idx == cursor_row && char_col == cursor_col { + char_col += 1; + continue; + } + + let screen_pos = ratatui::layout::Position::new(screen_x, screen_y); + + // Apply syntax highlighting. + if let Some((_, style)) = + spans.iter().find(|(r, _)| r.start <= byte_pos && byte_pos < r.end) + { + if let Some(cell) = buf.cell_mut(screen_pos) { + // Patch only the foreground colour; preserve bg, modifiers etc. + let current = cell.style(); + cell.set_style(current.patch(*style)); + } + } + + // Highlight the matching bracket (applied on top of syntax colour). + if Some(byte_pos) == paren_match_byte { + if let Some(cell) = buf.cell_mut(screen_pos) { + let current = cell.style(); + cell.set_style(current.patch(paren_style)); + } + } + + char_col += 1; + } + + byte_offset += line.len() + 1; // +1 for the '\n' joining lines + } + } + + fn render_running_pane(&self, f: &mut ratatui::Frame, area: Rect) { + let outer_block = Block::default() + .borders(Borders::TOP | Borders::BOTTOM) + .border_style(Style::default().fg(Color::DarkGray)); + let inner = outer_block.inner(area); + f.render_widget(outer_block, area); + + let spinner = SPINNER_FRAMES[(self.spinner_tick / 2) as usize % SPINNER_FRAMES.len()]; + let elapsed = if let Some(start) = self.query_start { + if self.progress_rows > 0 { + format!( + "{} {:.1}s {:>} rows received", + spinner, + start.elapsed().as_secs_f64(), + format_with_commas(self.progress_rows), + ) + } else { + format!("{} {:.1}s", spinner, start.elapsed().as_secs_f64()) + } + } else { + spinner.to_string() + }; + + let mut lines = vec![ + Line::from(Span::styled(elapsed, Style::default().fg(Color::Yellow))), + ]; + if !self.running_hint.is_empty() { + lines.push(Line::from(Span::styled( + self.running_hint.clone(), + Style::default().fg(Color::DarkGray), + ))); + } + + f.render_widget(Paragraph::new(lines), inner); + } + + fn render_history_search_popup( + f: &mut ratatui::Frame, + area: Rect, + hs: &HistorySearch, + entries: &[String], + highlighter: &SqlHighlighter, + ) { + use ratatui::{ + style::Modifier, + widgets::{Clear, List, ListItem}, + }; + + f.render_widget(Clear, area); + + let block = Block::default() + .title(" History Search (Enter accept · ↑/↓ navigate · Ctrl+R older · Esc close) ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + + let inner = block.inner(area); + f.render_widget(block, area); + + // Bottom-up layout: match list above, search line at bottom + let chunks = Layout::vertical([ + Constraint::Min(0), // match list (fills remaining space) + Constraint::Length(1), // search input line + ]) + .split(inner); + + // ── Search line (bottom) ──────────────────────────────────────────── + // Render the cursor by highlighting the character *under* it (or a + // space when at end-of-input), matching the style of the main editor. + let after_raw = hs.query_after_cursor(); + let (cursor_ch, tail): (String, &str) = if let Some(ch) = after_raw.chars().next() { + (ch.to_string(), &after_raw[ch.len_utf8()..]) + } else { + (" ".to_string(), "") + }; + let search_line = Line::from(vec![ + Span::styled("/ ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::styled(hs.query_before_cursor().to_string(), Style::default().fg(Color::White)), + Span::styled(cursor_ch, Style::default().fg(Color::Black).bg(Color::Cyan)), + Span::styled(tail.to_string(), Style::default().fg(Color::White)), + ]); + f.render_widget(Paragraph::new(search_line), chunks[1]); + + // ── Match list (above, rendered bottom-to-top) ───────────────────── + let list_h = chunks[0].height as usize; + let list_w = chunks[0].width as usize; + let all = hs.all_matches(); + let offset = hs.scroll_offset(); + + // Collect the visible slice then reverse it so most-recent is at bottom + let visible: Vec<(usize, usize)> = all + .iter() + .enumerate() + .skip(offset) + .take(list_h) + .map(|(idx, &entry_idx)| (idx, entry_idx)) + .collect::>() + .into_iter() + .rev() + .collect(); + + // Pad with blank rows at the top so results are bottom-aligned + let pad = list_h.saturating_sub(visible.len()); + let mut items: Vec = (0..pad) + .map(|_| ListItem::new(Line::from(""))) + .collect(); + + for (idx, entry_idx) in &visible { + let is_sel = *idx == hs.selected(); + let entry = &entries[*entry_idx]; + let display = history_search::format_entry_oneline(entry, list_w.saturating_sub(1)); + + let line = if is_sel { + // Selected row: solid cyan background, no syntax colours + let sel_style = Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD); + Line::from(Span::styled(display, sel_style)) + } else { + // Normal row: apply SQL syntax highlighting to the display text + let spans_meta = highlighter.highlight_to_spans(&display); + if spans_meta.is_empty() { + Line::from(Span::styled(display, Style::default().fg(Color::White))) + } else { + let mut parts: Vec = Vec::new(); + let mut last = 0usize; + for (range, style) in &spans_meta { + let s = range.start.min(display.len()); + let e = range.end.min(display.len()); + if s > last { + parts.push(Span::styled( + display[last..s].to_string(), + Style::default().fg(Color::White), + )); + } + if s < e { + parts.push(Span::styled(display[s..e].to_string(), *style)); + } + last = e; + } + if last < display.len() { + parts.push(Span::styled( + display[last..].to_string(), + Style::default().fg(Color::White), + )); + } + Line::from(parts) + } + }; + items.push(ListItem::new(line)); + } + + f.render_widget(List::new(items), chunks[0]); + } + + fn render_signature_hint( + f: &mut ratatui::Frame, + input_area: Rect, + total: Rect, + func_name: &str, + sigs: &[String], + ) -> Option { + use ratatui::{style::Modifier, widgets::{Clear, Paragraph}}; + + // ── Colour palette ──────────────────────────────────────────────── + let fname_style = Style::default().fg(Color::LightCyan); + let paren_style = Style::default().fg(Color::Indexed(250)); + let name_style = Style::default().fg(Color::White); + let type_style = Style::default().fg(Color::Yellow); + let arrow_style = Style::default().fg(Color::Indexed(240)); + let dflt_style = Style::default().fg(Color::Indexed(248)); + + // Maximum inner width available for content (leave margins) + let max_w = total.width.saturating_sub(6).max(30) as usize; + + // ── Helpers ─────────────────────────────────────────────────────── + + /// Split "func(p1, p2)" → ("func", ["p1", "p2"]). + /// Handles nested parens in type names (e.g. STRUCT(...)). + fn parse_sig(sig: &str) -> (&str, Vec<&str>) { + let Some(open) = sig.find('(') else { return (sig, vec![]) }; + let fname = &sig[..open]; + let inner = sig[open + 1..].trim_end_matches(')'); + if inner.is_empty() { return (fname, vec![]); } + // Split at ", " respecting nested parens + let mut params = Vec::new(); + let mut depth = 0usize; + let mut start = 0; + for (i, b) in inner.bytes().enumerate() { + match b { + b'(' => depth += 1, + b')' => depth = depth.saturating_sub(1), + b',' if depth == 0 => { + params.push(inner[start..i].trim()); + start = i + 1; + } + _ => {} + } + } + params.push(inner[start..].trim()); + (fname, params) + } + + /// Build styled spans for one param string. + /// Format: [name ]TYPE[...] [=> default] + /// A param has a name when its first character is lowercase. + fn style_param( + param: &str, + name_s: Style, + type_s: Style, + arrow_s: Style, + dflt_s: Style, + ) -> Vec> { + let (main, default) = if let Some(idx) = param.find(" => ") { + (¶m[..idx], Some(¶m[idx + 4..])) + } else { + (param, None) + }; + + let has_name = main.chars().next().map(|c| c.is_lowercase()).unwrap_or(false); + let mut spans: Vec> = Vec::new(); + + if has_name { + let sp = main.find(' ').unwrap_or(main.len()); + spans.push(Span::styled(main[..sp].to_owned(), name_s)); + spans.push(Span::raw(" ")); + spans.push(Span::styled(main[sp + 1..].to_owned(), type_s)); + } else { + spans.push(Span::styled(main.to_owned(), type_s)); + } + + if let Some(d) = default { + spans.push(Span::styled(" => ".to_owned(), arrow_s)); + spans.push(Span::styled(d.to_owned(), dflt_s)); + } + spans + } + + // ── Build display lines ─────────────────────────────────────────── + let mut all_lines: Vec = Vec::new(); + + let fallback; + let sig_strs: &[String] = if sigs.is_empty() { + fallback = vec![format!("{}()", func_name)]; + &fallback + } else { + sigs + }; + + for sig in sig_strs { + let (fname, params) = parse_sig(sig); + let fname_owned = fname.to_owned(); + + if sig.len() <= max_w { + // ── Single line ─────────────────────────────────────────── + let mut spans: Vec = vec![ + Span::styled(fname_owned, fname_style), + Span::styled("(".to_owned(), paren_style), + ]; + for (i, p) in params.iter().enumerate() { + if i > 0 { spans.push(Span::styled(", ".to_owned(), paren_style)); } + spans.extend(style_param(p, name_style, type_style, arrow_style, dflt_style)); + } + spans.push(Span::styled(")".to_owned(), paren_style)); + all_lines.push(Line::from(spans)); + } else { + // ── Flow-wrap: pack as many params per line as fit ──────── + // Continuation lines are indented to align under the first param. + let indent_w = fname.len() + 1; + let indent = " ".repeat(indent_w); + + let mut cur_spans: Vec = vec![ + Span::styled(fname_owned.clone(), fname_style), + Span::styled("(".to_owned(), paren_style), + ]; + let mut cur_w = indent_w; + + for (i, p) in params.iter().enumerate() { + let is_last = i == params.len() - 1; + let p_w = p.chars().count(); + // Cost of adding this param: p_w + 1 for ")" or p_w + 2 for ", " + let cost = p_w + if is_last { 1 } else { 2 }; + + if i > 0 && cur_w + cost > max_w { + // Wrap: end current line with trailing comma, start new + cur_spans.push(Span::styled(",".to_owned(), paren_style)); + all_lines.push(Line::from(std::mem::take(&mut cur_spans))); + cur_spans.push(Span::raw(indent.clone())); + cur_w = indent_w; + } else if i > 0 { + cur_spans.push(Span::styled(", ".to_owned(), paren_style)); + cur_w += 2; + } + + cur_spans.extend(style_param(p, name_style, type_style, arrow_style, dflt_style)); + cur_w += p_w; + + if is_last { + cur_spans.push(Span::styled(")".to_owned(), paren_style)); + } + } + if !cur_spans.is_empty() { + all_lines.push(Line::from(cur_spans)); + } + } + } + + // ── Compute popup dimensions ────────────────────────────────────── + let content_w = all_lines + .iter() + .map(|l| l.spans.iter().map(|s| s.content.chars().count()).sum::()) + .max() + .unwrap_or(10); + let popup_w = ((content_w + 4) as u16).min(total.width.saturating_sub(2)); + + // Clamp height: the popup must fit between row 0 and input_area.y. + // `input_area.y` is the number of rows above the input pane, which is + // the maximum space available for a popup anchored above it. + let max_popup_h = input_area.y.min(total.height); + if max_popup_h < 3 { return None; } // not enough room to show anything useful + let popup_h = (all_lines.len() as u16 + 2).min(max_popup_h); + + // Truncate content lines to fit the clamped height (border takes 2 rows). + let max_content = popup_h.saturating_sub(2) as usize; + let all_lines: Vec = all_lines.into_iter().take(max_content).collect(); + + // Position: just above the input area, right-aligned + let y = input_area.y.saturating_sub(popup_h); + let x = total.width.saturating_sub(popup_w + 1); + let rect = Rect::new(x, y, popup_w, popup_h); + + f.render_widget(Clear, rect); + + let title = Span::styled( + format!(" {} ", func_name), + Style::default().fg(Color::LightCyan).add_modifier(Modifier::BOLD), + ); + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Indexed(67))); + let inner = block.inner(rect); + f.render_widget(block, rect); + + f.render_widget(Paragraph::new(ratatui::text::Text::from(all_lines)), inner); + + Some(rect) + } + + fn render_help_popup(&self, f: &mut ratatui::Frame, area: Rect) { + use ratatui::{ + style::Modifier, + text::Span, + widgets::{BorderType, Clear, Paragraph, Wrap}, + }; + + // Build styled help lines + let section_style = Style::default() + .fg(Color::LightBlue) + .add_modifier(Modifier::BOLD); + let sep_style = Style::default().fg(Color::Indexed(240)); + let key_style = Style::default().fg(Color::LightYellow); + let desc_style = Style::default().fg(Color::White); + let cmd_style = Style::default().fg(Color::LightCyan); + + let keybinds: &[(&str, &str)] = &[ + ("Enter", "Submit query (if complete) or insert newline"), + ("Shift/Alt+Enter", "Always insert newline (even after `;`)"), + ("Ctrl+H", "Show / hide this help"), + ("Ctrl+D", "Exit"), + ("Ctrl+C", "Cancel input / cancel running query"), + ("Ctrl+V", "Open last result in csvlens viewer"), + ("Alt+E", "Open current query in $EDITOR"), + ("Ctrl+R", "Reverse history search"), + ("Ctrl+Space", "Fuzzy schema search"), + ("Ctrl+Up/Down", "Cycle through history (older / newer)"), + ("Ctrl+Z", "Undo last edit"), + ("Ctrl+Y", "Redo"), + ("Tab", "Open / navigate completion popup"), + ("Shift+Tab", "Navigate completion popup backwards"), + ("Alt+F", "Format SQL (uppercase keywords, 2-space indent)"), + ("Page Up/Down", "Scroll output pane"), + ("Mouse click", "Move cursor to clicked position"), + ("Shift+Drag", "Select text (most terminals)"), + ("Escape", "Close any open popup"), + ]; + let commands: &[(&str, &str)] = &[ + ("/exit", "Exit the REPL (also: /quit, exit, quit)"), + ("/run @", "Execute SQL queries from a file"), + ("/run ", "Execute an inline SQL query"), + ("/benchmark [N] @|", "Benchmark N timed runs + 1 warmup"), + ("/watch [N] @|", "Re-run every N secs; Ctrl+C stops"), + ("/qh [limit] [min]", "Query history (default: 100 rows, 60 min)"), + ("/refresh", "Refresh the schema completion cache"), + ("/view", "Open last result in csvlens viewer"), + ("set k=v;", "Set a query parameter"), + ("unset k;", "Remove a query parameter"), + ]; + + // Determine column widths dynamically from content. + let key_col = keybinds.iter().chain(commands.iter()).map(|(k, _)| k.len()).max().unwrap_or(14) + 2; + let max_desc = keybinds.iter().chain(commands.iter()).map(|(_, d)| d.len()).max().unwrap_or(30); + let sep_len = key_col + max_desc.max(28); + + let mut lines: Vec = Vec::new(); + + // Section: keyboard shortcuts + lines.push(Line::from(Span::styled(" Keyboard shortcuts", section_style))); + lines.push(Line::from(Span::styled( + format!(" {}", "─".repeat(sep_len)), + sep_style, + ))); + for (key, desc) in keybinds { + let padding = key_col.saturating_sub(key.len()); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(key.to_string(), key_style), + Span::raw(" ".repeat(padding)), + Span::styled(desc.to_string(), desc_style), + ])); + } + + lines.push(Line::from("")); + + // Section: special commands + lines.push(Line::from(Span::styled(" Slash commands", section_style))); + lines.push(Line::from(Span::styled( + format!(" {}", "─".repeat(sep_len)), + sep_style, + ))); + for (cmd, desc) in commands { + let padding = key_col.saturating_sub(cmd.len()); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(cmd.to_string(), cmd_style), + Span::raw(" ".repeat(padding)), + Span::styled(desc.to_string(), desc_style), + ])); + } + + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("Press Esc or q to close", sep_style), + ])); + + // Sizing: compute popup width from actual content width. + let inner_w = 2 + sep_len; // " " prefix + separator/item content + let content_w = inner_w as u16 + 2; // + 2 for left/right borders + let content_h = lines.len() as u16 + 2; + + let popup_w = content_w.min(area.width.saturating_sub(6)); + let popup_h = content_h.min(area.height.saturating_sub(4)); + let x = area.x + area.width.saturating_sub(popup_w) / 2; + let y = area.y + area.height.saturating_sub(popup_h) / 2; + let popup_rect = Rect::new(x, y, popup_w, popup_h); + + // Shadow: render a dark rect shifted 1 cell right + 1 cell down + if popup_rect.right() + 1 < area.right() && popup_rect.bottom() + 1 < area.bottom() { + let shadow_rect = Rect::new( + popup_rect.x + 1, + popup_rect.y + 1, + popup_rect.width, + popup_rect.height, + ); + f.render_widget( + Paragraph::new("").style(Style::default().bg(Color::Indexed(232))), + shadow_rect, + ); + } + + // Main popup background + border + f.render_widget(Clear, popup_rect); + let block = Block::default() + .title(Span::styled( + " ✦ Help · Esc / q to close ", + Style::default().fg(Color::LightBlue).add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .border_type(BorderType::Double) + .border_style(Style::default().fg(Color::Indexed(67))) // steel blue + .style(Style::default().bg(Color::Indexed(234))); // dark background + + let inner = block.inner(popup_rect); + f.render_widget(block, popup_rect); + f.render_widget( + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .style(Style::default().bg(Color::Indexed(234))), + inner, + ); + } + + fn render_status_bar(&mut self, f: &mut ratatui::Frame, area: Rect) { + let host = if !self.context.args.unix_socket.is_empty() { + &self.context.args.unix_socket + } else { + &self.context.args.host + }; + let db = &self.context.args.database; + + // Show cursor position (1-based) when the textarea is visible. + let cursor_pos = if !self.is_running { + let (row, col) = self.textarea.cursor(); + format!(" L{}:C{} ", row + 1, col + 1) + } else { + String::new() + }; + + let conn_info = format!(" {} | {}{}", host, db, cursor_pos); + + // Expire flash messages older than 2 seconds. + if let Some((_, t)) = &self.flash_message { + if t.elapsed() >= Duration::from_secs(2) { + self.flash_message = None; + } + } + + let right = if self.is_running { + " Ctrl+C cancel ".to_string() + } else if self.history_search.is_some() { + " Enter accept Ctrl+R older Esc cancel ".to_string() + } else if self.fuzzy_state.is_some() { + " Enter accept ↑/↓ navigate Esc close ".to_string() + } else if self.completion_state.is_some() { + " Enter accept Tab/↑/↓ navigate Esc close ".to_string() + } else { + " Ctrl+H help Ctrl+D exit Ctrl+Space fuzzy Alt+E editor Ctrl+V viewer Alt+F format Tab complete".to_string() + }; + + let total = area.width as usize; + let in_txn = self.context.in_transaction(); + + let status = if let Some((msg, _)) = &self.flash_message { + // Flash: error on the left in red, hint line on the right in normal style. + let flash_left = format!(" {} ", msg); + let pad = total.saturating_sub(flash_left.len() + right.len()); + let gap = " ".repeat(pad); + let spans: Vec = vec![ + Span::styled(flash_left + &gap, Style::default().bg(Color::Red).fg(Color::White)), + Span::styled(right, Style::default().bg(Color::DarkGray).fg(Color::White)), + ]; + Paragraph::new(Line::from(spans)) + } else { + let base = Style::default().bg(Color::DarkGray).fg(Color::White); + let disconnected = !self.connected && !self.context.args.no_completion; + // When disconnected: show conn_info + "No server connection" badge in red. + let (left_text, conn_style) = if disconnected { + let disconnected_label = format!("{} \u{2717} No server connection ", conn_info); + (disconnected_label, Style::default().bg(Color::Red).fg(Color::White)) + } else { + (conn_info.clone(), base) + }; + + if in_txn { + // Transaction active: show a yellow "TXN" badge between conn info and hints. + let badge = " TXN "; + let pad = total.saturating_sub(left_text.len() + badge.len() + right.len()); + let txn_style = Style::default() + .bg(Color::Indexed(130)) // dark orange + .fg(Color::White) + .add_modifier(Modifier::BOLD); + let spans: Vec = vec![ + Span::styled(format!("{}{}", left_text, " ".repeat(pad)), conn_style), + Span::styled(badge, txn_style), + Span::styled(right, base), + ]; + Paragraph::new(Line::from(spans)) + } else { + let pad = total.saturating_sub(left_text.len() + right.len()); + let spans: Vec = vec![ + Span::styled(format!("{}{}", left_text, " ".repeat(pad)), conn_style), + Span::styled(right, base), + ]; + Paragraph::new(Line::from(spans)) + } + }; + f.render_widget(status, area); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_benchmark_args_default() { + let (n, q) = parse_benchmark_args("/benchmark SELECT 1"); + assert_eq!(n, 3); + assert_eq!(q, "SELECT 1"); + } + + #[test] + fn test_parse_benchmark_args_explicit_n() { + let (n, q) = parse_benchmark_args("/benchmark 5 SELECT 1"); + assert_eq!(n, 5); + assert_eq!(q, "SELECT 1"); + } + + #[test] + fn test_parse_benchmark_args_no_query() { + let (n, q) = parse_benchmark_args("/benchmark"); + assert_eq!(n, 3); + assert_eq!(q, ""); + } + + #[test] + fn test_parse_benchmark_args_min_n() { + // 0 should be clamped to 1 + let (n, _) = parse_benchmark_args("/benchmark 0 SELECT 1"); + assert_eq!(n, 1); + } + + #[test] + fn test_format_with_commas() { + assert_eq!(format_with_commas(0), "0"); + assert_eq!(format_with_commas(999), "999"); + assert_eq!(format_with_commas(1000), "1,000"); + assert_eq!(format_with_commas(1234567), "1,234,567"); + } + + #[test] + fn test_strip_outer_quotes() { + assert_eq!(strip_outer_quotes("bare"), "bare"); + assert_eq!(strip_outer_quotes("\"my file.sql\""), "my file.sql"); + assert_eq!(strip_outer_quotes("'my file.sql'"), "my file.sql"); + // Mismatched quotes → unchanged + assert_eq!(strip_outer_quotes("\"foo'"), "\"foo'"); + // Single-char string → unchanged (too short to strip) + assert_eq!(strip_outer_quotes("\""), "\""); + // Empty string → unchanged + assert_eq!(strip_outer_quotes(""), ""); + // Nested quotes: only outer layer stripped + assert_eq!(strip_outer_quotes("\"'inner'\""), "'inner'"); + } + + #[test] + fn test_find_matching_paren_forward() { + // cursor on '(' at position 3 — match is ')' at position 9 + let s = "foo(bar)baz"; + assert_eq!(find_matching_paren(s, 3), Some(7)); + } + + #[test] + fn test_find_matching_paren_backward() { + // cursor on ')' at position 7 — match is '(' at position 3 + let s = "foo(bar)baz"; + assert_eq!(find_matching_paren(s, 7), Some(3)); + } + + #[test] + fn test_find_matching_paren_nested() { + // "foo(bar(baz)qux)" — cursor on outer '(' at 3, match ')' at 15 + let s = "foo(bar(baz)qux)"; + assert_eq!(find_matching_paren(s, 3), Some(15)); + // cursor on inner '(' at 7, match ')' at 11 + assert_eq!(find_matching_paren(s, 7), Some(11)); + } + + #[test] + fn test_find_matching_paren_no_match() { + // Unmatched '(' — no closing paren + assert_eq!(find_matching_paren("foo(bar", 3), None); + // Cursor not on a bracket + assert_eq!(find_matching_paren("foo(bar)baz", 0), None); + // Cursor beyond end + assert_eq!(find_matching_paren("foo", 10), None); + } + + #[test] + fn test_find_matching_paren_square_brackets() { + let s = "arr[1]"; + assert_eq!(find_matching_paren(s, 3), Some(5)); + assert_eq!(find_matching_paren(s, 5), Some(3)); + } + + #[test] + fn test_find_matching_paren_multiline() { + // Newline between parens (joined text from textarea lines) + let s = "SELECT (\n 1 + 2\n)"; + // '(' is at byte 7, ')' is at the last byte + assert_eq!(find_matching_paren(s, 7), Some(s.len() - 1)); + } + + // ── slash_cmd_sql_offset ───────────────────────────────────────────────── + + #[test] + fn test_slash_sql_offset_same_line() { + // SQL immediately after command + space + assert_eq!(slash_cmd_sql_offset("/benchmark SELECT 1", false), Some(11)); + assert_eq!(slash_cmd_sql_offset("/watch SELECT 1", false), Some(7)); + assert_eq!(slash_cmd_sql_offset("/run SELECT 1", false), Some(5)); + } + + #[test] + fn test_slash_sql_offset_same_line_with_count() { + // /benchmark 5 SELECT — count is skipped + let full = "/benchmark 5 SELECT 1"; + assert_eq!(slash_cmd_sql_offset("/benchmark 5 SELECT 1", false), Some(13)); + // Verify the split is correct + let offset = slash_cmd_sql_offset("/benchmark 5 SELECT 1", false).unwrap(); + assert_eq!(&full[..offset], "/benchmark 5 "); + assert_eq!(&full[offset..], "SELECT 1"); + } + + #[test] + fn test_slash_sql_offset_next_line_bare_command() { + // /benchmark\nSELECT — SQL on next line, no trailing space + let first = "/benchmark"; + let full = "/benchmark\nSELECT 1"; + let offset = slash_cmd_sql_offset(first, true).unwrap(); + assert_eq!(&full[..offset], "/benchmark\n"); + assert_eq!(&full[offset..], "SELECT 1"); + } + + #[test] + fn test_slash_sql_offset_next_line_with_count() { + // /benchmark 5\nSELECT — count on first line, SQL on second + let first = "/benchmark 5"; + let full = "/benchmark 5\nSELECT 1"; + let offset = slash_cmd_sql_offset(first, true).unwrap(); + assert_eq!(&full[..offset], "/benchmark 5\n"); + assert_eq!(&full[offset..], "SELECT 1"); + } + + #[test] + fn test_slash_sql_offset_no_match() { + // Plain SQL — not a slash command + assert_eq!(slash_cmd_sql_offset("SELECT 1", false), None); + // Bare command with no SQL and no next line + assert_eq!(slash_cmd_sql_offset("/benchmark", false), None); + assert_eq!(slash_cmd_sql_offset("/benchmark 5", false), None); + // Unknown command + assert_eq!(slash_cmd_sql_offset("/foo SELECT 1", false), None); + } +} diff --git a/src/tui/output_pane.rs b/src/tui/output_pane.rs new file mode 100644 index 0000000..722bf0c --- /dev/null +++ b/src/tui/output_pane.rs @@ -0,0 +1,346 @@ +use crate::tui_msg::{TuiColor, TuiLine, TuiSpan}; +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Paragraph, Widget}, +}; +use std::ops::Range; + +// ── OutputLine ──────────────────────────────────────────────────────────────── + +/// A single pre-rendered line in the output pane. +pub struct OutputLine { + content: Line<'static>, +} + +impl OutputLine { + fn from_line(content: Line<'static>) -> Self { + Self { content } + } +} + +// ── OutputPane ──────────────────────────────────────────────────────────────── + +/// Scrollable output pane that accumulates lines from query results. +pub struct OutputPane { + lines: Vec, + /// Scroll offset in lines. `usize::MAX` is a sentinel meaning "scroll to bottom", + /// resolved to the correct value by `clamp_scroll` before each render. + scroll: usize, +} + +impl OutputPane { + pub fn new() -> Self { + Self { lines: Vec::new(), scroll: 0 } + } + + /// Push text that may contain ANSI SGR escape codes (e.g. from comfy-table). + /// Splits on `\n` and parses each sub-line into styled spans. + pub fn push_ansi_text(&mut self, text: &str) { + for line in text.split('\n') { + self.lines.push(OutputLine::from_line(parse_ansi_line(line))); + } + self.scroll_to_bottom(); + } + + /// Push a plain unstyled line. + pub fn push_line(&mut self, line: impl Into) { + let s: String = line.into(); + self.lines.push(OutputLine::from_line(Line::from(s))); + self.scroll_to_bottom(); + } + + /// Push the echoed prompt line with syntax highlighting. + /// + /// `prefix` is `"❯ "` for the first line or `" "` for continuation lines. + /// `sql_line` is the raw text of one SQL line. + /// `spans` are pre-computed highlight spans whose byte offsets are relative to `sql_line`. + pub fn push_prompt_highlighted( + &mut self, + prefix: &str, + sql_line: &str, + spans: &[(Range, Style)], + ) { + let prefix_span: Span<'static> = if prefix == "❯ " { + Span::styled( + "❯ ".to_string(), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + ) + } else { + Span::raw(prefix.to_string()) + }; + + let mut parts: Vec> = vec![prefix_span]; + let mut last = 0usize; + for (range, style) in spans { + let start = range.start.min(sql_line.len()); + let end = range.end.min(sql_line.len()); + if start > last { + parts.push(Span::raw(sql_line[last..start].to_string())); + } + if start < end { + parts.push(Span::styled(sql_line[start..end].to_string(), *style)); + } + last = end; + } + if last < sql_line.len() { + parts.push(Span::raw(sql_line[last..].to_string())); + } + + self.lines.push(OutputLine::from_line(Line::from(parts))); + self.scroll_to_bottom(); + } + + /// Push a timing / scan-statistics line — dark gray. + pub fn push_stat(&mut self, text: &str) { + for line in text.split('\n') { + let span = Span::styled( + line.to_string(), + Style::default().fg(Color::DarkGray), + ); + self.lines.push(OutputLine::from_line(Line::from(span))); + } + self.scroll_to_bottom(); + } + + /// Push pre-styled lines produced by the TUI table renderer. + pub fn push_tui_lines(&mut self, lines: Vec) { + for tui_line in lines { + let spans: Vec> = tui_line.0.into_iter().map(tui_span_to_ratatui).collect(); + self.lines.push(OutputLine::from_line(Line::from(spans))); + } + self.scroll_to_bottom(); + } + + /// Push an error line — red. + pub fn push_error(&mut self, text: &str) { + for line in text.split('\n') { + let span = Span::styled(line.to_string(), Style::default().fg(Color::Red)); + self.lines.push(OutputLine::from_line(Line::from(span))); + } + self.scroll_to_bottom(); + } + + pub fn scroll_up(&mut self, amount: u16) { + self.scroll = self.scroll.saturating_sub(amount as usize); + } + + pub fn scroll_down(&mut self, amount: u16) { + self.scroll = self.scroll.saturating_add(amount as usize); + } + + fn scroll_to_bottom(&mut self) { + self.scroll = usize::MAX; // sentinel — resolved in clamp_scroll + } + + /// Clamp scroll so we don't go past the end of content. + /// `visible_width` is used to compute the number of visual (wrapped) lines. + pub fn clamp_scroll(&mut self, visible_height: u16, visible_width: u16) { + let width = visible_width as usize; + let total: usize = self.lines.iter() + .map(|ol| visual_line_count(&ol.content, width)) + .sum(); + let height = visible_height as usize; + if total <= height { + self.scroll = 0; + } else if self.scroll == usize::MAX || self.scroll > total - height { + self.scroll = total - height; + } + // else: user-scrolled position is still in range, leave it alone + } + + /// Render only the visible slice of lines. + /// Content is bottom-anchored: empty padding fills the top so output + /// grows upward from the input area, like a normal terminal. + /// Long lines are wrapped at `area.width` characters. + pub fn render(&self, area: Rect, buf: &mut Buffer) { + let width = area.width as usize; + let height = area.height as usize; + + // Expand stored lines into visual (wrapped) lines. + let visual: Vec> = self.lines + .iter() + .flat_map(|ol| wrap_line(&ol.content, width)) + .collect(); + + let start = self.scroll; + let end = (start + height).min(visual.len()); + let slice = &visual[start..end]; + + // Pad with blank lines above so content sits at the bottom. + let padding = height.saturating_sub(slice.len()); + let visible: Vec> = std::iter::repeat_n(Line::raw(""), padding) + .chain(slice.iter().cloned()) + .collect(); + + Widget::render(Paragraph::new(visible), area, buf); + } +} + +// ── Line wrapping helpers ───────────────────────────────────────────────────── + +/// Count how many visual lines a stored `Line` occupies when the terminal is +/// `width` columns wide. One empty line still counts as one visual line. +fn visual_line_count(line: &Line<'static>, width: usize) -> usize { + if width == 0 { + return 1; + } + let chars: usize = line.spans.iter().map(|s| s.content.chars().count()).sum(); + if chars == 0 { 1 } else { chars.div_ceil(width) } +} + +/// Expand a `Line<'static>` into one or more visual lines of at most `width` +/// characters, splitting across span boundaries as needed. +fn wrap_line(line: &Line<'static>, width: usize) -> Vec> { + if width == 0 { + return vec![line.clone()]; + } + let total: usize = line.spans.iter().map(|s| s.content.chars().count()).sum(); + if total <= width { + return vec![line.clone()]; + } + + let mut result: Vec> = Vec::new(); + let mut current_spans: Vec> = Vec::new(); + let mut current_len: usize = 0; + + for span in &line.spans { + let mut seg_start = 0usize; // byte offset into span.content for the current segment + + for (byte_idx, _ch) in span.content.char_indices() { + if current_len == width { + // Flush: push the portion of this span accumulated so far. + if byte_idx > seg_start { + current_spans.push(Span::styled( + span.content[seg_start..byte_idx].to_string(), + span.style, + )); + } + result.push(Line::from(std::mem::take(&mut current_spans))); + current_len = 0; + seg_start = byte_idx; + } + current_len += 1; + } + + // Flush the remainder of this span. + if seg_start < span.content.len() { + current_spans.push(Span::styled( + span.content[seg_start..].to_string(), + span.style, + )); + } + } + + if !current_spans.is_empty() { + result.push(Line::from(current_spans)); + } + + result +} + +// ── TuiSpan → ratatui Span conversion ──────────────────────────────────────── + +fn tui_span_to_ratatui(span: TuiSpan) -> Span<'static> { + let color = match span.color { + TuiColor::Default => Color::Reset, + TuiColor::Cyan => Color::Cyan, + TuiColor::DarkGray => Color::DarkGray, + }; + let mut style = Style::default().fg(color); + if span.bold { + style = style.add_modifier(Modifier::BOLD); + } + Span::styled(span.text, style) +} + +// ── ANSI SGR parser ─────────────────────────────────────────────────────────── + +/// Parse a string that may contain ANSI SGR escape sequences (`\x1b[...m`) into +/// a ratatui `Line` of styled spans. Unrecognised codes are silently ignored. +fn parse_ansi_line(text: &str) -> Line<'static> { + let mut spans: Vec> = Vec::new(); + let mut current_style = Style::default(); + let bytes = text.as_bytes(); + let mut seg_start = 0; // byte index of start of current unstyled segment + let mut i = 0; + + while i < bytes.len() { + // Look for ESC [ + if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'[' { + // Flush the text segment before this escape sequence. + if seg_start < i { + spans.push(Span::styled(text[seg_start..i].to_string(), current_style)); + } + // Scan forward to the terminating letter. + let param_start = i + 2; + let mut j = param_start; + while j < bytes.len() && !bytes[j].is_ascii_alphabetic() { + j += 1; + } + if j < bytes.len() && bytes[j] == b'm' { + // SGR sequence — update style. + let params = &text[param_start..j]; + current_style = apply_sgr(current_style, params); + i = j + 1; + } else { + // Non-SGR or malformed — skip the ESC byte and retry. + i += 1; + } + seg_start = i; + } else { + i += 1; + } + } + + // Flush any remaining text. + if seg_start < text.len() { + spans.push(Span::styled(text[seg_start..].to_string(), current_style)); + } + + Line::from(spans) +} + +/// Apply a semicolon-separated list of SGR parameters to `style`. +fn apply_sgr(style: Style, params: &str) -> Style { + // Empty params or "0" alone means reset. + if params.is_empty() || params == "0" { + return Style::default(); + } + let mut s = style; + for param in params.split(';') { + s = match param { + "0" => Style::default(), + "1" => s.add_modifier(Modifier::BOLD), + "2" => s.add_modifier(Modifier::DIM), + "3" => s.add_modifier(Modifier::ITALIC), + "4" => s.add_modifier(Modifier::UNDERLINED), + "22" => s.remove_modifier(Modifier::BOLD), + "23" => s.remove_modifier(Modifier::ITALIC), + "24" => s.remove_modifier(Modifier::UNDERLINED), + // Standard foreground colours + "30" => s.fg(Color::Black), + "31" => s.fg(Color::Red), + "32" => s.fg(Color::Green), + "33" => s.fg(Color::Yellow), + "34" => s.fg(Color::Blue), + "35" => s.fg(Color::Magenta), + "36" => s.fg(Color::Cyan), + "37" => s.fg(Color::White), + "39" => s.fg(Color::Reset), + // Bright foreground colours + "90" => s.fg(Color::DarkGray), + "91" => s.fg(Color::LightRed), + "92" => s.fg(Color::LightGreen), + "93" => s.fg(Color::LightYellow), + "94" => s.fg(Color::LightBlue), + "95" => s.fg(Color::LightMagenta), + "96" => s.fg(Color::LightCyan), + "97" => s.fg(Color::White), + _ => s, // ignore unrecognised params + }; + } + s +} diff --git a/src/tui/signature_hint.rs b/src/tui/signature_hint.rs new file mode 100644 index 0000000..944ae87 --- /dev/null +++ b/src/tui/signature_hint.rs @@ -0,0 +1,244 @@ +/// Detect whether the cursor is currently inside a SQL function call. +/// +/// Walks the SQL text up to the cursor position, tracking open/close +/// parentheses with a stack. Each `(` pushed records the identifier +/// immediately before it (the function name) or `None` for grouping +/// expressions. Returns the innermost function name on the stack, if any. + +/// Walk `sql[..cursor]` and return the name of the innermost function call +/// the cursor is currently inside, or `None`. +pub fn detect_function_at_cursor(sql: &str, cursor: usize) -> Option { + let bytes = sql.as_bytes(); + let end = cursor.min(bytes.len()); + // Stack of Option: Some(name) for function calls, None for + // plain grouping parens like `(1 + 2)`. + let mut stack: Vec> = Vec::new(); + let mut i = 0; + + while i < end { + match bytes[i] { + // Single-quoted string + b'\'' => { + i += 1; + while i < end { + match bytes[i] { + b'\'' => { i += 1; break; } + b'\\' => i += 2, + _ => i += 1, + } + } + } + // Dollar-quoted string $$...$$ + b'$' if i + 1 < end && bytes[i + 1] == b'$' => { + i += 2; + while i + 1 < end { + if bytes[i] == b'$' && bytes[i + 1] == b'$' { i += 2; break; } + i += 1; + } + } + // Double-quoted identifier + b'"' => { + i += 1; + while i < end && bytes[i] != b'"' { i += 1; } + if i < end { i += 1; } + } + // Line comment + b'-' if i + 1 < end && bytes[i + 1] == b'-' => { + while i < end && bytes[i] != b'\n' { i += 1; } + } + // Block comment + b'/' if i + 1 < end && bytes[i + 1] == b'*' => { + i += 2; + while i + 1 < end { + if bytes[i] == b'*' && bytes[i + 1] == b'/' { i += 2; break; } + i += 1; + } + } + b'(' => { + let name = ident_before(sql, i); + stack.push(name); + i += 1; + } + b')' => { + stack.pop(); + i += 1; + } + _ => i += 1, + } + } + + // Innermost function name (ignore grouping-paren Nones) + stack.into_iter().rev().find_map(|x| x) +} + +#[allow(dead_code)] +/// The byte offset in `sql` where the currently-active function name starts, +/// or `None`. Used to position the popup horizontally. +pub fn func_name_start(sql: &str, cursor: usize) -> Option { + let bytes = sql.as_bytes(); + let end = cursor.min(bytes.len()); + let mut stack: Vec> = Vec::new(); // (name_start, name) + let mut i = 0; + + while i < end { + match bytes[i] { + b'\'' => { + i += 1; + while i < end { + match bytes[i] { + b'\'' => { i += 1; break; } + b'\\' => i += 2, + _ => i += 1, + } + } + } + b'$' if i + 1 < end && bytes[i + 1] == b'$' => { + i += 2; + while i + 1 < end { + if bytes[i] == b'$' && bytes[i + 1] == b'$' { i += 2; break; } + i += 1; + } + } + b'"' => { + i += 1; + while i < end && bytes[i] != b'"' { i += 1; } + if i < end { i += 1; } + } + b'-' if i + 1 < end && bytes[i + 1] == b'-' => { + while i < end && bytes[i] != b'\n' { i += 1; } + } + b'/' if i + 1 < end && bytes[i + 1] == b'*' => { + i += 2; + while i + 1 < end { + if bytes[i] == b'*' && bytes[i + 1] == b'/' { i += 2; break; } + i += 1; + } + } + b'(' => { + let entry = ident_before_with_start(sql, i); + stack.push(entry); + i += 1; + } + b')' => { stack.pop(); i += 1; } + _ => i += 1, + } + } + + stack.into_iter().rev().find_map(|x| x).map(|(start, _)| start) +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/// Extract the SQL identifier that ends immediately before byte position `pos` +/// (trimming trailing whitespace), lowercased. Returns `None` for keywords +/// that open a sub-expression (`CASE`, `ARRAY`, …) or when nothing is found. +fn ident_before(sql: &str, pos: usize) -> Option { + ident_before_with_start(sql, pos).map(|(_, name)| name) +} + +fn ident_before_with_start(sql: &str, pos: usize) -> Option<(usize, String)> { + let before = &sql[..pos]; + let trimmed = before.trim_end(); + if trimmed.is_empty() { return None; } + + let end_byte = trimmed.len(); + // Walk backwards through the trimmed slice while chars are ident chars + let mut start_byte = end_byte; + for (byte_idx, ch) in trimmed.char_indices().rev() { + if ch.is_alphanumeric() || ch == '_' { + start_byte = byte_idx; + } else { + break; + } + } + + let name = &trimmed[start_byte..end_byte]; + if name.is_empty() || name.chars().next()?.is_ascii_digit() { + return None; + } + + // Filter out SQL keywords that look like identifiers but aren't function calls. + // These are reserved words that can appear before `(` in non-call contexts. + let upper = name.to_uppercase(); + if matches!( + upper.as_str(), + "SELECT" | "FROM" | "WHERE" | "JOIN" | "INNER" | "LEFT" | "RIGHT" | "FULL" + | "OUTER" | "CROSS" | "ON" | "USING" | "GROUP" | "ORDER" | "HAVING" + | "LIMIT" | "OFFSET" | "UNION" | "INTERSECT" | "EXCEPT" | "WITH" + | "INSERT" | "INTO" | "UPDATE" | "SET" | "DELETE" | "CREATE" | "DROP" + | "ALTER" | "TABLE" | "VIEW" | "INDEX" | "DATABASE" | "SCHEMA" + | "CASE" | "WHEN" | "THEN" | "ELSE" | "END" + | "IN" | "NOT" | "AND" | "OR" | "BETWEEN" | "LIKE" | "ILIKE" + | "IS" | "AS" | "BY" | "AT" | "DISTINCT" | "ALL" | "EXISTS" + | "PRIMARY" | "FOREIGN" | "KEY" | "REFERENCES" | "UNIQUE" | "CHECK" + | "DEFAULT" | "CONSTRAINT" | "CASCADE" | "RESTRICT" + | "BEGIN" | "COMMIT" | "ROLLBACK" | "TRANSACTION" + | "EXPLAIN" | "DESCRIBE" | "SHOW" | "USE" + ) { + return None; + } + + Some((start_byte, name.to_lowercase())) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inside_simple_call() { + assert_eq!( + detect_function_at_cursor("SELECT upper(", 13), + Some("upper".to_string()) + ); + } + + #[test] + fn outside_after_close() { + assert_eq!(detect_function_at_cursor("SELECT upper(x)", 15), None); + } + + #[test] + fn nested_call() { + // cursor inside inner call: coalesce(upper( + let sql = "SELECT coalesce(upper("; + assert_eq!( + detect_function_at_cursor(sql, sql.len()), + Some("upper".to_string()) + ); + } + + #[test] + fn between_calls() { + // cursor after closing inner `)` but inside outer + let sql = "SELECT coalesce(upper(x), "; + assert_eq!( + detect_function_at_cursor(sql, sql.len()), + Some("coalesce".to_string()) + ); + } + + #[test] + fn grouping_paren_ignored() { + // `(1 + 2)` — no function name before `(` + assert_eq!(detect_function_at_cursor("SELECT (1 + 2", 13), None); + } + + #[test] + fn inside_string_not_detected() { + // The `(` is inside a string literal + let sql = "SELECT 'upper("; + assert_eq!(detect_function_at_cursor(sql, sql.len()), None); + } + + #[test] + fn multiline_sql() { + let sql = "SELECT\n upper(\n col"; + assert_eq!( + detect_function_at_cursor(sql, sql.len()), + Some("upper".to_string()) + ); + } +} diff --git a/src/tui_msg.rs b/src/tui_msg.rs new file mode 100644 index 0000000..b455300 --- /dev/null +++ b/src/tui_msg.rs @@ -0,0 +1,47 @@ +/// Messages sent from the query thread to the TUI output pane via the mpsc channel. +pub enum TuiMsg { + /// A plain text line (may contain ANSI escape codes). + Line(String), + /// Pre-styled lines produced by the custom TUI table renderer. + StyledLines(Vec), + /// Streaming progress update: total data rows received so far. + Progress(u64), + /// The parsed result of the last successful SELECT query (for csvlens / export). + ParsedResult(crate::table_renderer::ParsedResult), + /// Overwrite the running-pane hint line (e.g. benchmark run progress). + RunHint(String), + /// Propagate server-driven parameter changes back to the TUI's own context. + /// Sent whenever `firebolt-update-parameters`, `firebolt-remove-parameters`, + /// or `firebolt-reset-session` response headers are received so that the + /// transaction badge (and future features) reflect the updated state. + ParamUpdate(Vec), + /// Report whether a schema-cache refresh (or ping) succeeded. + /// `true` = server reachable; `false` = connection failed. + ConnectionStatus(bool), +} + +/// A single rendered line made up of zero or more styled spans. +pub struct TuiLine(pub Vec); + +/// A styled run of text. +pub struct TuiSpan { + pub text: String, + pub color: TuiColor, + pub bold: bool, +} + +impl TuiSpan { + pub fn plain(text: impl Into) -> Self { + Self { text: text.into(), color: TuiColor::Default, bold: false } + } + pub fn styled(text: impl Into, color: TuiColor, bold: bool) -> Self { + Self { text: text.into(), color, bold } + } +} + +#[derive(Clone, Copy)] +pub enum TuiColor { + Default, + Cyan, + DarkGray, +} diff --git a/src/utils.rs b/src/utils.rs index 0a7c785..e9bb4a9 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,3 +1,4 @@ +use std::env; use std::fs; use std::io::stderr; use std::io::Write; @@ -32,6 +33,13 @@ pub fn sa_token_path() -> Result> { Ok(init_root_path()?.join("fb_sa_token")) } +/// Get path for temporary CSV file in system temp directory +pub fn temp_csv_path() -> Result> { + let mut path = env::temp_dir(); + path.push(format!("fb_result_{}.csv", std::process::id())); + Ok(path) +} + // Format remaining time for token validity pub fn format_remaining_time(time: SystemTime, maybe_more: String) -> Result> { let remaining = time.duration_since(SystemTime::now())?.as_secs(); @@ -83,4 +91,12 @@ mod tests { let sa_token = sa_token_path().unwrap(); assert!(sa_token.ends_with("fb_sa_token")); } + + #[test] + fn test_temp_csv_path() { + let path = temp_csv_path().unwrap(); + let file_name = path.file_name().unwrap().to_str().unwrap(); + assert!(file_name.starts_with("fb_result_")); + assert!(file_name.ends_with(".csv")); + } } diff --git a/src/viewer.rs b/src/viewer.rs new file mode 100644 index 0000000..343e69b --- /dev/null +++ b/src/viewer.rs @@ -0,0 +1,171 @@ +use crate::context::Context; +use crate::table_renderer::write_result_as_csv; +use crate::utils::temp_csv_path; +use std::fs::File; + +/// Open csvlens viewer for the last query result +pub fn open_csvlens_viewer(context: &Context) -> Result<(), Box> { + // csvlens only works with client-side rendering (when last_result is populated) + if !context.args.format.starts_with("client:") { + return Err("csvlens viewer requires client-side rendering. Use --format client:auto or similar.".into()); + } + + // Check if we have a result to display + let result = match &context.last_result { + Some(r) => r, + None => return Err("No query results to display. Run a query first.".into()), + }; + + // Check for errors in last result + if let Some(ref errors) = result.errors { + let msgs: Vec<&str> = errors.iter().map(|e| e.description.as_str()).collect(); + return Err(format!("Cannot display results — last query had errors: {}", msgs.join("; ")).into()); + } + + // Check if result is empty + if result.columns.is_empty() { + return Err("No data to display (no columns in result).".into()); + } + + if result.rows.is_empty() { + return Err("Query returned 0 rows. Nothing to display.".into()); + } + + // Write result to temporary CSV file + let csv_path = temp_csv_path()?; + let mut file = File::create(&csv_path)?; + write_result_as_csv(&mut file, &result.columns, &result.rows)?; + drop(file); // Ensure file is flushed and closed + + // Launch csvlens viewer + let csv_path_str = csv_path.to_string_lossy().to_string(); + let options = csvlens::CsvlensOptions { + filename: Some(csv_path_str), + ..Default::default() + }; + + let result = csvlens::run_csvlens_with_options(options); + let _ = std::fs::remove_file(&csv_path); + result.map(|_| ()).map_err(|e| Box::new(e) as Box) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::table_renderer::{ErrorDetail, ParsedResult, ResultColumn}; + use serde_json::Value; + + #[test] + fn test_no_result_error() { + let mut args = crate::args::get_args().unwrap(); + args.format = String::from("client:auto"); + let context = Context::new(args); + + let result = open_csvlens_viewer(&context); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("No query results")); + } + + #[test] + fn test_error_result() { + let mut args = crate::args::get_args().unwrap(); + args.format = String::from("client:auto"); + let mut context = Context::new(args); + context.last_result = Some(ParsedResult { + columns: vec![], + rows: vec![], + statistics: None, + errors: Some(vec![ErrorDetail { + description: "Test error".to_string(), + }]), + }); + + let result = open_csvlens_viewer(&context); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Test error")); + } + + #[test] + fn test_empty_columns() { + let mut args = crate::args::get_args().unwrap(); + args.format = String::from("client:auto"); + let mut context = Context::new(args); + context.last_result = Some(ParsedResult { + columns: vec![], + rows: vec![], + statistics: None, + errors: None, + }); + + let result = open_csvlens_viewer(&context); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("no columns")); + } + + #[test] + fn test_empty_rows() { + let mut args = crate::args::get_args().unwrap(); + args.format = String::from("client:auto"); + let mut context = Context::new(args); + context.last_result = Some(ParsedResult { + columns: vec![ResultColumn { + name: "col1".to_string(), + column_type: "int".to_string(), + }], + rows: vec![], + statistics: None, + errors: None, + }); + + let result = open_csvlens_viewer(&context); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("0 rows")); + } + + #[test] + fn test_csv_file_creation() { + let mut args = crate::args::get_args().unwrap(); + args.format = String::from("client:auto"); + let mut context = Context::new(args); + context.last_result = Some(ParsedResult { + columns: vec![ + ResultColumn { + name: "id".to_string(), + column_type: "int".to_string(), + }, + ResultColumn { + name: "name".to_string(), + column_type: "text".to_string(), + }, + ], + rows: vec![ + vec![Value::Number(1.into()), Value::String("Alice".to_string())], + vec![Value::Number(2.into()), Value::String("Bob".to_string())], + ], + statistics: None, + errors: None, + }); + + // This test verifies that the CSV file is created and written correctly + // We can't test the actual csvlens launch without a terminal, but we can + // verify the file creation part + let csv_path = temp_csv_path().unwrap(); + let mut file = File::create(&csv_path).unwrap(); + write_result_as_csv( + &mut file, + &context.last_result.as_ref().unwrap().columns, + &context.last_result.as_ref().unwrap().rows, + ) + .unwrap(); + drop(file); + + // Verify file exists and has content + let content = std::fs::read_to_string(&csv_path).unwrap(); + assert!(content.contains("id,name")); + assert!(content.contains("1,Alice")); + assert!(content.contains("2,Bob")); + + // Clean up + std::fs::remove_file(&csv_path).ok(); + } +} diff --git a/tests/cli.rs b/tests/cli.rs index 35166a4..55d529e 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,6 +1,6 @@ +use serde_json; use std::io::Write; use std::process::Command; -use serde_json; fn run_fb(args: &[&str]) -> (bool, String, String) { let output = Command::new(env!("CARGO_BIN_EXE_fb")) @@ -14,6 +14,20 @@ fn run_fb(args: &[&str]) -> (bool, String, String) { (output.status.success(), stdout, stderr) } +/// Like run_fb but returns the exact exit code instead of a bool. +fn run_fb_code(args: &[&str]) -> (i32, String, String) { + let output = Command::new(env!("CARGO_BIN_EXE_fb")) + .args(args) + .output() + .expect("Failed to execute command"); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let stderr = String::from_utf8(output.stderr).unwrap(); + let code = output.status.code().unwrap_or(-1); + + (code, stdout, stderr) +} + #[test] fn test_basic_query() { let (success, stdout, _) = run_fb(&["--core", "SELECT 42"]); @@ -24,7 +38,7 @@ fn test_basic_query() { #[test] fn test_set_format() { // First set format to TSV - let (success, stdout, _) = run_fb(&["--core", "--concise", "-f", "TabSeparatedWithNamesAndTypes", "SELECT 42;"]); + let (success, stdout, _) = run_fb(&["--core", "-f", "TabSeparatedWithNamesAndTypes", "SELECT 42;"]); assert!(success); assert_eq!(stdout, "?column?\nint\n42\n"); } @@ -64,7 +78,6 @@ fn test_params_escaping() { let mut child = Command::new(env!("CARGO_BIN_EXE_fb")) .args(&[ "--core", - "--concise", "-f", "TabSeparatedWithNamesAndTypes", "-e", @@ -76,11 +89,11 @@ fn test_params_escaping() { .unwrap(); let mut stdin = child.stdin.take().unwrap(); - writeln!(stdin, "SELECT param('$1');").unwrap(); + writeln!(stdin, "SELECT param('$1') AS param;").unwrap(); writeln!(stdin, "SET advanced_mode=true;").unwrap(); - writeln!(stdin, "SELECT param('$1');").unwrap(); + writeln!(stdin, "SELECT param('$1') AS param;").unwrap(); writeln!(stdin, r#"SET query_parameters={{"name": "$1", "value": "b=}}&"}};"#).unwrap(); - writeln!(stdin, "SELECT param('$1');").unwrap(); + writeln!(stdin, "SELECT param('$1') AS param;").unwrap(); drop(stdin); // Close stdin to end interactive mode let output = child.wait_with_output().unwrap(); @@ -89,19 +102,44 @@ fn test_params_escaping() { assert!(output.status.success()); let mut lines = stdout.lines(); // First query result - assert_eq!(lines.next().unwrap(), "?column?"); + assert_eq!(lines.next().unwrap(), "param"); assert_eq!(lines.next().unwrap(), "text"); assert_eq!(lines.next().unwrap(), "a=}&"); // Second query result - assert_eq!(lines.next().unwrap(), "?column?"); + assert_eq!(lines.next().unwrap(), "param"); assert_eq!(lines.next().unwrap(), "text"); assert_eq!(lines.next().unwrap(), "a=}&"); // Third query result - assert_eq!(lines.next().unwrap(), "?column?"); + assert_eq!(lines.next().unwrap(), "param"); assert_eq!(lines.next().unwrap(), "text"); assert_eq!(lines.next().unwrap(), "b=}&"); } +#[test] +fn test_param_flag() { + // Integer, string, boolean and NULL params; verify they substitute correctly. + let (success, stdout, _) = run_fb(&[ + "--core", + "-f", "TabSeparatedWithNamesAndTypes", + "-p", "42", + "-p", "hello", + "-p", "true", + "-p", "NULL", + "SELECT param('$1') AS p1, param('$2') AS p2, param('$3') AS p3, param('$4') AS p4;", + ]); + assert!(success, "query with -p params should succeed"); + let mut lines = stdout.lines(); + assert_eq!(lines.next().unwrap(), "p1\tp2\tp3\tp4"); + // TabSeparatedWithNamesAndTypes emits type names on the second line + let _types = lines.next().unwrap(); + let values = lines.next().unwrap(); + let cols: Vec<&str> = values.split('\t').collect(); + assert_eq!(cols[0], "42"); + assert_eq!(cols[1], "hello"); + assert_eq!(cols[2], "true"); + assert_eq!(cols[3], ""); // NULL in Firebolt TSV format is empty string +} + #[test] fn test_argument_parsing_space_separated() { // Test space-separated argument format: --host localhost --database testdb @@ -181,12 +219,7 @@ fn test_command_parsing() { #[test] fn test_exiting() { let mut child = Command::new(env!("CARGO_BIN_EXE_fb")) - .args(&[ - "--core", - "--concise", - "-f", - "TabSeparatedWithNamesAndTypes", - ]) + .args(&["--core", "-f", "TabSeparatedWithNamesAndTypes"]) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .spawn() @@ -215,8 +248,8 @@ fn test_json_output_fully_parseable() { assert!(success); - // stderr should contain stats - assert!(stderr.contains("Time:"), "stderr should contain Time:"); + // server-side format: no stats on either stream + assert!(!stderr.contains("Time:") && !stdout.contains("Time:"), "server-side format: no timing stats"); // stdout should be valid JSON Lines - each non-empty line should be valid JSON let trimmed_stdout = stdout.trim(); @@ -240,7 +273,7 @@ fn test_json_output_fully_parseable() { #[test] fn test_exit_code_on_connection_error() { // Test that exit code is non-zero when server is not available - let (success, _, stderr) = run_fb(&["--host", "localhost:59999", "--concise", "SELECT 1"]); + let (success, _, stderr) = run_fb(&["--host", "localhost:59999", "SELECT 1"]); assert!(!success, "Exit code should be non-zero when connection fails"); assert!( @@ -253,14 +286,15 @@ fn test_exit_code_on_connection_error() { #[test] fn test_exit_code_on_query_error() { // Test that exit code is non-zero when query returns an error (e.g., syntax error) - let (success, stdout, _) = run_fb(&["--core", "--concise", "SELEC INVALID SYNTAX"]); + let (success, stdout, stderr) = run_fb(&["--core", "SELEC INVALID SYNTAX"]); assert!(!success, "Exit code should be non-zero when query fails"); - // The server should return an error message in the response + // The server should return an error message in stdout or stderr + let combined = format!("{}{}", stdout, stderr); assert!( - stdout.to_lowercase().contains("error") || stdout.to_lowercase().contains("exception"), - "stdout should contain error message from server, got: {}", - stdout + combined.to_lowercase().contains("error") || combined.to_lowercase().contains("exception"), + "output should contain error message from server, got stdout: {} stderr: {}", + stdout, stderr ); } @@ -268,7 +302,7 @@ fn test_exit_code_on_query_error() { fn test_exit_code_on_query_error_interactive() { // Test that exit code is non-zero when any query fails in interactive mode let mut child = Command::new(env!("CARGO_BIN_EXE_fb")) - .args(&["--core", "--concise"]) + .args(&["--core"]) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) @@ -288,3 +322,414 @@ fn test_exit_code_on_query_error_interactive() { "Exit code should be non-zero when any query in session fails" ); } + +#[test] +fn test_auto_format() { + let (success, stdout, _) = run_fb(&["--core", "--format=client:auto", "SELECT 1 as id, 'test' as name"]); + assert!(success); + assert!(stdout.contains("id")); + assert!(stdout.contains("name")); + assert!(stdout.contains("test")); +} + +#[test] +fn test_expanded_format() { + let (success, stdout, _) = run_fb(&["--core", "--format=client:vertical", "SELECT 1 as id, 'test' as name"]); + assert!(success); + assert!(stdout.contains("Row 1:")); + assert!(stdout.contains("id")); + assert!(stdout.contains("name")); + assert!(stdout.contains("test")); +} + +#[test] +fn test_wide_table_auto_expanded() { + // Query with many columns — layout depends on terminal width, just verify data is present + let (success, stdout, _) = run_fb(&[ + "--core", + "--format=client:auto", + "SELECT 1 as a, 2 as b, 3 as c, 4 as d, 5 as e, 6 as f, \ + 7 as g, 8 as h, 9 as i, 10 as j, 11 as k, 12 as l, 13 as m", + ]); + assert!(success); + assert!(stdout.contains('a') && stdout.contains('m')); // Column headers present + assert!(stdout.contains('1') && stdout.contains("13")); // Values present +} + +#[test] +fn test_narrow_table_stays_horizontal() { + // Query with few columns should stay horizontal + let (success, stdout, _) = run_fb(&["--core", "--format=client:auto", "SELECT 1 as id, 'test' as name"]); + assert!(success); + assert!(!stdout.contains("Row 1:")); // Should NOT use vertical + assert!(stdout.contains("id")); // But still contains data +} + +#[test] +fn test_client_format_horizontal() { + let (success, stdout, _) = run_fb(&["--core", "--format=client:horizontal", "SELECT 1 as id, 'test' as name"]); + assert!(success); + + // Should have horizontal table format + assert!(stdout.contains("id")); + assert!(stdout.contains("name")); + assert!(stdout.contains("test")); + assert!(stdout.contains('│')); // Has column separators (Unicode box-drawing) + + // Should NOT use vertical format + assert!(!stdout.contains("Row 1")); +} + +#[test] +fn test_client_format_vertical() { + let (success, stdout, _) = run_fb(&["--core", "--format=client:vertical", "SELECT 1 as id, 'test' as name"]); + assert!(success); + + // Should have vertical format + assert!(stdout.contains("Row 1")); + assert!(stdout.contains("id")); + assert!(stdout.contains("name")); +} + +#[test] +fn test_client_format_auto() { + // Auto should choose based on terminal width + let (success, stdout, _) = run_fb(&["--core", "--format=client:auto", "SELECT 1 as id"]); + assert!(success); + + // Should have table format + assert!(stdout.contains('│')); // Has table borders (Unicode box-drawing) + assert!(stdout.contains("id")); +} + +#[test] +fn test_server_format_json() { + // Server-side format (no client: prefix) + let (success, stdout, _) = run_fb(&["--core", "--format=JSON_Compact", "SELECT 1 as id"]); + assert!(success); + + // Should have JSON format from server + assert!(stdout.contains('{')); // JSON + assert!(!stdout.contains('+')); // Not a table +} + +#[test] +fn test_server_format_psql() { + let (success, stdout, _) = run_fb(&["--core", "--format=PSQL", "SELECT 1 as id"]); + assert!(success); + + // Should have PSQL format from server + assert!(!stdout.contains('+')); // No table borders (PSQL style is different) + assert!(stdout.contains("id")); +} + +// ── Exit code precision ────────────────────────────────────────────────────── + +#[test] +fn test_exit_code_query_error_is_1() { + let (code, _, _) = run_fb_code(&["--core", "SELEC INVALID SYNTAX"]); + assert_eq!(code, 1, "bad SQL should exit with code 1, not {}", code); +} + +#[test] +fn test_exit_code_system_error_is_2() { + let (code, _, stderr) = run_fb_code(&["--host", "localhost:59999", "SELECT 1"]); + assert_eq!(code, 2, "connection error should exit with code 2, not {}; stderr: {}", code, stderr); +} + +// ── exit command ───────────────────────────────────────────────────────────── + +#[test] +fn test_exit_command() { + // 'exit' should work the same as 'quit' + let mut child = Command::new(env!("CARGO_BIN_EXE_fb")) + .args(&["--core", "-f", "TabSeparatedWithNamesAndTypes"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .spawn() + .unwrap(); + + let mut stdin = child.stdin.take().unwrap(); + writeln!(stdin, "SELECT 99;").unwrap(); + writeln!(stdin, "exit").unwrap(); + drop(stdin); + + let output = child.wait_with_output().unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(output.status.success()); + assert!(stdout.contains("99")); +} + +// ── Default format ─────────────────────────────────────────────────────────── + +#[test] +fn test_default_format_is_client_auto() { + // Without --format, output should use client:auto (bordered table) + let (success, stdout, _) = run_fb(&["--core", "SELECT 1 as id"]); + assert!(success); + assert!(stdout.contains('│'), "default format should produce table borders (client:auto)"); + assert!(stdout.contains("id")); +} + +// ── stdout / stderr separation ─────────────────────────────────────────────── + +#[test] +fn test_stats_on_stderr_not_stdout() { + // Server-side format: no stats on either stream (clean output for scripting) + let (success, stdout, stderr) = run_fb(&["--core", "-f", "TabSeparatedWithNamesAndTypes", "SELECT 42"]); + assert!(success); + assert!(!stdout.contains("Time:"), "server-side format: no timing on stdout"); + assert!(!stderr.contains("Time:"), "server-side format: no timing on stderr"); + + // Client-side format: stats follow the table on stdout + let (success, stdout, _) = run_fb(&["--core", "SELECT 42"]); + assert!(success); + assert!(stdout.contains("Time:"), "client-side format: timing should be on stdout"); +} + +// ── Scripting output formats ───────────────────────────────────────────────── + +#[test] +fn test_json_compact_output() { + let (success, stdout, _) = run_fb(&["--core", "--format=JSON_Compact", "SELECT 1 AS n"]); + assert!(success); + let parsed: serde_json::Value = serde_json::from_str(stdout.trim()) + .expect("JSON_Compact output should be valid JSON"); + assert!(parsed.get("data").is_some(), "JSON_Compact should have a 'data' field"); + assert!(parsed.get("meta").is_some(), "JSON_Compact should have a 'meta' field"); +} + +#[test] +fn test_tsv_output() { + let (success, stdout, _) = run_fb(&["--core", "--format=TabSeparatedWithNamesAndTypes", "SELECT 42 AS answer"]); + assert!(success); + assert!(stdout.contains("answer"), "TabSeparatedWithNamesAndTypes should include header"); + assert!(stdout.contains("42")); +} + +// ── Pipe mode ──────────────────────────────────────────────────────────────── + +#[test] +fn test_pipe_mode_multiple_queries_in_order() { + let mut child = Command::new(env!("CARGO_BIN_EXE_fb")) + .args(&["--core", "-f", "TabSeparatedWithNamesAndTypes"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .spawn() + .unwrap(); + + let mut stdin = child.stdin.take().unwrap(); + writeln!(stdin, "SELECT 10;").unwrap(); + writeln!(stdin, "SELECT 20;").unwrap(); + writeln!(stdin, "SELECT 30;").unwrap(); + drop(stdin); + + let output = child.wait_with_output().unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(output.status.success()); + // All three results should appear in order + let pos10 = stdout.find("10").expect("first result missing"); + let pos20 = stdout.find("20").expect("second result missing"); + let pos30 = stdout.find("30").expect("third result missing"); + assert!(pos10 < pos20 && pos20 < pos30, "results should appear in order"); +} + +#[test] +fn test_pipe_mode_continues_after_error() { + // A failed query in the middle should not abort subsequent queries + let mut child = Command::new(env!("CARGO_BIN_EXE_fb")) + .args(&["--core", "-f", "TabSeparatedWithNamesAndTypes"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + + let mut stdin = child.stdin.take().unwrap(); + writeln!(stdin, "SELECT 10;").unwrap(); + writeln!(stdin, "SELEC INVALID;").unwrap(); // fails + writeln!(stdin, "SELECT 30;").unwrap(); // should still run + drop(stdin); + + let output = child.wait_with_output().unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(!output.status.success(), "exit code should be non-zero due to failed query"); + assert!(stdout.contains("10"), "first query result should appear"); + assert!(stdout.contains("30"), "third query result should appear despite middle failure"); +} + +// ── Transaction support ─────────────────────────────────────────────────────── + +/// Helper: spawn fb in pipe mode and feed lines; return (success, stdout, stderr). +fn run_pipe(extra_args: &[&str], lines: &[&str]) -> (bool, String, String) { + let mut child = Command::new(env!("CARGO_BIN_EXE_fb")) + .args(extra_args) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + + let mut stdin = child.stdin.take().unwrap(); + for line in lines { + writeln!(stdin, "{}", line).unwrap(); + } + drop(stdin); + + let output = child.wait_with_output().unwrap(); + ( + output.status.success(), + String::from_utf8(output.stdout).unwrap(), + String::from_utf8(output.stderr).unwrap(), + ) +} + +#[test] +fn test_transaction_begin_commit_succeeds() { + let (ok, stdout, stderr) = run_pipe( + &["--core", "-f", "TabSeparatedWithNamesAndTypes"], + &["BEGIN;", "SELECT 42;", "COMMIT;"], + ); + assert!(ok, "BEGIN/COMMIT sequence should exit 0; stderr: {}", stderr); + assert!(stdout.contains("42"), "SELECT inside transaction should return data"); +} + +#[test] +fn test_transaction_begin_rollback_succeeds() { + let (ok, _stdout, stderr) = run_pipe( + &["--core", "-f", "TabSeparatedWithNamesAndTypes"], + &["BEGIN;", "SELECT 1;", "ROLLBACK;"], + ); + assert!(ok, "BEGIN/ROLLBACK sequence should exit 0; stderr: {}", stderr); +} + +#[test] +fn test_transaction_id_appears_in_url_after_begin() { + // --verbose prints the updated URL after each response. + // After BEGIN the server adds Firebolt-Update-Parameters, so the next + // URL (printed by the verbose handler) must contain transaction_id. + let (ok, _stdout, stderr) = run_pipe( + &["--core", "--verbose", "-f", "TabSeparatedWithNamesAndTypes"], + &["BEGIN;", "SELECT 1;", "COMMIT;"], + ); + assert!(ok, "sequence should succeed; stderr: {}", stderr); + assert!( + stderr.contains("transaction_id"), + "URL emitted after BEGIN must contain transaction_id; stderr: {}", + stderr + ); +} + +#[test] +fn test_transaction_id_absent_from_url_after_commit() { + // After COMMIT the transaction_id must be removed from the URL so that + // subsequent queries don't carry a stale transaction context. + let (ok, stdout, stderr) = run_pipe( + &["--core", "--verbose", "-f", "TabSeparatedWithNamesAndTypes"], + // Extra SELECT after COMMIT: its URL line must not carry transaction_id. + &["BEGIN;", "SELECT 1;", "COMMIT;", "SELECT 99;"], + ); + assert!(ok, "full sequence should succeed; stderr: {}", stderr); + assert!(stdout.contains("99"), "SELECT after COMMIT should produce output"); + + // Find all "URL:" lines that appear after the last "COMMIT" reference. + // The URL for the post-COMMIT SELECT must not carry transaction_id. + let lines: Vec<&str> = stderr.lines().collect(); + let commit_pos = lines.iter().rposition(|l| l.contains("COMMIT")); + if let Some(pos) = commit_pos { + let post_commit_urls: Vec<&str> = lines[pos..] + .iter() + .filter(|l| l.starts_with("URL:")) + .copied() + .collect(); + assert!( + !post_commit_urls.is_empty(), + "expected at least one URL line after COMMIT" + ); + for url_line in &post_commit_urls { + assert!( + !url_line.contains("transaction_id"), + "URL after COMMIT must not carry transaction_id: {}", + url_line + ); + } + } +} + +#[test] +fn test_transaction_id_absent_from_url_after_rollback() { + let (ok, stdout, stderr) = run_pipe( + &["--core", "--verbose", "-f", "TabSeparatedWithNamesAndTypes"], + &["BEGIN;", "SELECT 1;", "ROLLBACK;", "SELECT 77;"], + ); + assert!(ok, "full sequence should succeed; stderr: {}", stderr); + assert!(stdout.contains("77"), "SELECT after ROLLBACK should produce output"); + + let lines: Vec<&str> = stderr.lines().collect(); + let rollback_pos = lines.iter().rposition(|l| l.contains("ROLLBACK")); + if let Some(pos) = rollback_pos { + let post_rollback_urls: Vec<&str> = lines[pos..] + .iter() + .filter(|l| l.starts_with("URL:")) + .copied() + .collect(); + for url_line in &post_rollback_urls { + assert!( + !url_line.contains("transaction_id"), + "URL after ROLLBACK must not carry transaction_id: {}", + url_line + ); + } + } +} + +#[test] +fn test_transaction_dml_commit() { + // Full DML cycle: insert inside transaction, commit, verify data persists. + let table = format!("test_tx_commit_{}", std::process::id()); + let create = format!("CREATE TABLE {} (x INT);", table); + let insert = format!("INSERT INTO {} VALUES (1234);", table); + let select = format!("SELECT x FROM {};", table); + let drop = format!("DROP TABLE {};", table); + + let (ok, stdout, stderr) = run_pipe( + &["--core", "-f", "TabSeparatedWithNamesAndTypes"], + &[&create, "BEGIN;", &insert, "COMMIT;", &select, &drop], + ); + assert!(ok, "DML commit sequence should succeed; stderr: {}", stderr); + assert!(stdout.contains("1234"), "committed row must be visible after COMMIT"); +} + +#[test] +fn test_transaction_dml_rollback() { + // Insert inside a transaction then roll back — the row must not persist. + let table = format!("test_tx_rollback_{}", std::process::id()); + let create = format!("CREATE TABLE {} (x INT);", table); + let insert = format!("INSERT INTO {} VALUES (9999);", table); + let select = format!("SELECT x FROM {};", table); + let drop = format!("DROP TABLE {};", table); + + // All steps in one session: CREATE, BEGIN, INSERT, ROLLBACK, SELECT, DROP. + // The SELECT runs after the ROLLBACK so its output must not contain 9999. + let (ok, stdout, stderr) = run_pipe( + &["--core", "-f", "TabSeparatedWithNamesAndTypes"], + &[&create, "BEGIN;", &insert, "ROLLBACK;", &select, &drop], + ); + assert!(ok, "DML rollback sequence should succeed; stderr: {}", stderr); + assert!(!stdout.contains("9999"), "rolled-back row must not appear after ROLLBACK"); +} + +#[test] +fn test_transaction_sequential_transactions() { + // Two independent transactions back-to-back on the same connection. + let (ok, stdout, stderr) = run_pipe( + &["--core", "-f", "TabSeparatedWithNamesAndTypes"], + &[ + "BEGIN;", "SELECT 11;", "COMMIT;", + "BEGIN;", "SELECT 22;", "COMMIT;", + ], + ); + assert!(ok, "sequential transactions should succeed; stderr: {}", stderr); + assert!(stdout.contains("11"), "first transaction result missing"); + assert!(stdout.contains("22"), "second transaction result missing"); +}