From 0a814d085c5f0068e5e1fead9caa02f9e5afe7fd Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Tue, 9 Jun 2026 22:19:02 +0200 Subject: [PATCH 1/4] feat: add terminal CLI with interactive SQL shell - New clap subcommands: connections, databases, schemas, tables, describe, query, install-cli - `query ` without SQL opens an interactive shell (rustyline): multi-line statements, persistent history, psql-style meta commands (\dt, \d, \l, \dn, \use, \f, \limit, \schema) - Multi-database connections: -d/--database on each command and \use in the shell, mirroring the GUI's per-call database override - Extract shared headless connection resolution (keychain, SSH/K8s tunnels, driver registry) from the MCP server into headless.rs - Route keychain logging through the log crate instead of raw stdout - Install sqlx Any drivers in headless mode (test_connection panicked without them) - install-cli symlinks the binary into /usr/local/bin or ~/.local/bin --- src-tauri/Cargo.lock | 73 ++++++- src-tauri/Cargo.toml | 1 + src-tauri/src/cli.rs | 52 ----- src-tauri/src/cli/args_tests.rs | 157 ++++++++++++++ src-tauri/src/cli/install.rs | 97 +++++++++ src-tauri/src/cli/install_tests.rs | 99 +++++++++ src-tauri/src/cli/mod.rs | 182 ++++++++++++++++ src-tauri/src/cli/output.rs | 138 ++++++++++++ src-tauri/src/cli/output_tests.rs | 134 ++++++++++++ src-tauri/src/cli/repl.rs | 282 ++++++++++++++++++++++++ src-tauri/src/cli/run.rs | 338 +++++++++++++++++++++++++++++ src-tauri/src/cli/run_tests.rs | 52 +++++ src-tauri/src/headless.rs | 214 ++++++++++++++++++ src-tauri/src/keychain_utils.rs | 42 ++-- src-tauri/src/lib.rs | 16 ++ src-tauri/src/mcp/mod.rs | 252 +-------------------- 16 files changed, 1810 insertions(+), 319 deletions(-) delete mode 100644 src-tauri/src/cli.rs create mode 100644 src-tauri/src/cli/args_tests.rs create mode 100644 src-tauri/src/cli/install.rs create mode 100644 src-tauri/src/cli/install_tests.rs create mode 100644 src-tauri/src/cli/mod.rs create mode 100644 src-tauri/src/cli/output.rs create mode 100644 src-tauri/src/cli/output_tests.rs create mode 100644 src-tauri/src/cli/repl.rs create mode 100644 src-tauri/src/cli/run.rs create mode 100644 src-tauri/src/cli/run_tests.rs create mode 100644 src-tauri/src/headless.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0a5b7f76..bdb72f49 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1610,6 +1610,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "endian-type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" + [[package]] name = "enumflags2" version = "0.7.12" @@ -3104,9 +3110,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" -version = "0.2.182" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libdbus-sys" @@ -3419,6 +3425,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + [[package]] name = "nix" version = "0.29.0" @@ -3432,6 +3447,18 @@ dependencies = [ "memoffset", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -4699,6 +4726,16 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +[[package]] +name = "radix_trie" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "rand" version = "0.7.3" @@ -5294,6 +5331,27 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustyline" +version = "18.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a990b25f351b25139ddc7f21ee3f6f56f86d6846b74ac8fad3a719a287cd4a0" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "clipboard-win", + "home", + "libc", + "log", + "memchr", + "nix 0.31.3", + "radix_trie", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "windows-sys 0.61.2", +] + [[package]] name = "ryu" version = "1.0.23" @@ -6188,7 +6246,7 @@ dependencies = [ [[package]] name = "tabularis" -version = "0.13.0" +version = "0.13.1" dependencies = [ "async-trait", "base64 0.22.1", @@ -6214,6 +6272,7 @@ dependencies = [ "rustls", "rustls-pemfile", "rustls-platform-verifier", + "rustyline", "serde", "serde_json", "serde_yaml", @@ -7256,6 +7315,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -8659,7 +8724,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.29.0", "ordered-stream", "rand 0.8.5", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7f9285c8..7644d54c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -51,6 +51,7 @@ once_cell = "1.20" csv = "1.4.0" reqwest = { version = "0.13.1", features = ["json"] } clap = { version = "4.5.56", features = ["derive"] } +rustyline = "18.0.0" directories = "6.0.0" serde_yaml = "0.9.34" sysinfo = { version = "0.32", features = ["system"] } diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs deleted file mode 100644 index 65d34290..00000000 --- a/src-tauri/src/cli.rs +++ /dev/null @@ -1,52 +0,0 @@ -//! Command-line argument parsing for the Tabularis binary. -//! -//! Keeping this in its own module means `lib.rs` does not have to know about -//! clap, and the flag surface (`--mcp`, `--debug`, `--explain`, `--help`, -//! `--version`) lives in one place. - -use clap::Parser; - -#[derive(Parser, Debug)] -#[command(version, about, long_about = None)] -pub struct Args { - /// Start in MCP Server mode (Model Context Protocol) - #[arg(long)] - pub mcp: bool, - - /// Enable debug logging (including sqlx queries) - #[arg(long)] - pub debug: bool, - - /// Open a Visual Explain window for a previously-saved EXPLAIN file - /// (Postgres `EXPLAIN (FORMAT JSON)` output). - #[arg(long, value_name = "FILE")] - pub explain: Option, -} - -impl Args { - fn defaults() -> Self { - Self { - mcp: false, - debug: false, - explain: None, - } - } -} - -/// Parse the process arguments, with platform-friendly fallback behaviour. -/// -/// - `--help` / `--version` surface as `Err(DisplayHelp|DisplayVersion)` with the -/// formatted message attached; let clap print them and exit cleanly. -/// - Any other parse failure falls back to defaults so that GUI launches (e.g. -/// macOS passing `-psn_*`) still reach the Tauri builder. -pub fn parse() -> Args { - Args::try_parse().unwrap_or_else(|err| { - if matches!( - err.kind(), - clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion - ) { - err.exit(); - } - Args::defaults() - }) -} diff --git a/src-tauri/src/cli/args_tests.rs b/src-tauri/src/cli/args_tests.rs new file mode 100644 index 00000000..9169c251 --- /dev/null +++ b/src-tauri/src/cli/args_tests.rs @@ -0,0 +1,157 @@ +use super::{Args, CliCommand, OutputFormat}; +use clap::error::ErrorKind; +use clap::Parser; + +// --- GUI launch compatibility ------------------------------------------------ + +#[test] +fn no_arguments_means_gui_launch() { + let args = Args::try_parse_from(["tabularis"]).unwrap(); + assert!(args.command.is_none()); + assert!(!args.mcp); + assert!(!args.debug); + assert!(args.explain.is_none()); +} + +#[test] +fn legacy_flags_still_parse() { + let args = + Args::try_parse_from(["tabularis", "--mcp", "--debug", "--explain", "plan.json"]).unwrap(); + assert!(args.mcp); + assert!(args.debug); + assert_eq!(args.explain.as_deref(), Some("plan.json")); + assert!(args.command.is_none()); +} + +#[test] +fn macos_psn_argument_is_unknown_argument_error() { + // `parse()` falls back to GUI defaults on this kind only; a regression + // here would make Finder launches die on the clap error path. + let err = Args::try_parse_from(["tabularis", "-psn_0_42"]).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::UnknownArgument); +} + +#[test] +fn misspelled_subcommand_is_invalid_subcommand_error() { + // `parse()` must surface this to the user instead of opening the GUI. + let err = Args::try_parse_from(["tabularis", "quer", "conn"]).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidSubcommand); +} + +// --- query ------------------------------------------------------------------- + +#[test] +fn query_with_sql_parses_with_defaults() { + let args = Args::try_parse_from(["tabularis", "query", "conn-1", "select 1"]).unwrap(); + match args.command { + Some(CliCommand::Query { + connection, + sql, + database, + limit, + format, + schema, + }) => { + assert_eq!(connection, "conn-1"); + assert_eq!(sql.as_deref(), Some("select 1")); + assert_eq!(database, None); + assert_eq!(limit, 100); + assert_eq!(format, OutputFormat::Table); + assert_eq!(schema, None); + } + other => panic!("expected Query, got {:?}", other), + } +} + +#[test] +fn query_without_sql_parses_for_shell_mode() { + let args = Args::try_parse_from(["tabularis", "query", "conn-1"]).unwrap(); + match args.command { + Some(CliCommand::Query { sql, .. }) => assert!(sql.is_none()), + other => panic!("expected Query, got {:?}", other), + } +} + +#[test] +fn query_accepts_short_database_flag() { + let args = Args::try_parse_from(["tabularis", "query", "conn-1", "-d", "blog_demo"]).unwrap(); + match args.command { + Some(CliCommand::Query { database, .. }) => { + assert_eq!(database.as_deref(), Some("blog_demo")); + } + other => panic!("expected Query, got {:?}", other), + } +} + +#[test] +fn query_format_accepts_known_values_only() { + let args = + Args::try_parse_from(["tabularis", "query", "c", "select 1", "--format", "csv"]).unwrap(); + match args.command { + Some(CliCommand::Query { format, .. }) => assert_eq!(format, OutputFormat::Csv), + other => panic!("expected Query, got {:?}", other), + } + + let err = Args::try_parse_from(["tabularis", "query", "c", "s", "--format", "xml"]) + .unwrap_err(); + assert_eq!(err.kind(), ErrorKind::InvalidValue); +} + +#[test] +fn query_alias_q_works() { + let args = Args::try_parse_from(["tabularis", "q", "conn-1", "select 1"]).unwrap(); + assert!(matches!(args.command, Some(CliCommand::Query { .. }))); +} + +// --- other subcommands --------------------------------------------------------- + +#[test] +fn connections_alias_ls_works() { + let args = Args::try_parse_from(["tabularis", "ls"]).unwrap(); + assert!(matches!( + args.command, + Some(CliCommand::Connections { json: false }) + )); +} + +#[test] +fn tables_parses_database_and_schema() { + let args = Args::try_parse_from([ + "tabularis", "tables", "conn-1", "-d", "db2", "--schema", "public", "--json", + ]) + .unwrap(); + match args.command { + Some(CliCommand::Tables { + connection, + database, + schema, + json, + }) => { + assert_eq!(connection, "conn-1"); + assert_eq!(database.as_deref(), Some("db2")); + assert_eq!(schema.as_deref(), Some("public")); + assert!(json); + } + other => panic!("expected Tables, got {:?}", other), + } +} + +#[test] +fn describe_requires_table_argument() { + let err = Args::try_parse_from(["tabularis", "describe", "conn-1"]).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument); +} + +#[test] +fn install_cli_parses_dir_and_force() { + let args = + Args::try_parse_from(["tabularis", "install-cli", "--dir", "/tmp/bin", "--force"]) + .unwrap(); + match args.command { + Some(CliCommand::InstallCli { dir, force }) => { + assert_eq!(dir.as_deref(), Some(std::path::Path::new("/tmp/bin"))); + assert!(force); + } + other => panic!("expected InstallCli, got {:?}", other), + } +} diff --git a/src-tauri/src/cli/install.rs b/src-tauri/src/cli/install.rs new file mode 100644 index 00000000..ca91a1e0 --- /dev/null +++ b/src-tauri/src/cli/install.rs @@ -0,0 +1,97 @@ +//! `tabularis install-cli` — make the `tabularis` command available in the +//! user's PATH via a symlink in a bin directory pointing at the running +//! binary (which on macOS lives inside the .app bundle). +//! +//! First run is done with the full path, e.g.: +//! `/Applications/tabularis.app/Contents/MacOS/tabularis install-cli` + +use std::path::{Path, PathBuf}; + +/// Candidate bin directories, in order of preference. `/usr/local/bin` is +/// already in PATH on macOS but usually needs elevated permissions; +/// `~/.local/bin` always works but may need a PATH addition. +fn candidate_dirs() -> Vec { + let mut dirs = vec![PathBuf::from("/usr/local/bin")]; + if let Some(home) = std::env::var_os("HOME") { + dirs.push(Path::new(&home).join(".local/bin")); + } + dirs +} + +/// Returns whether `dir` is listed in the current `$PATH`. +fn dir_in_path(dir: &Path) -> bool { + std::env::var_os("PATH") + .map(|path| std::env::split_paths(&path).any(|p| p == dir)) + .unwrap_or(false) +} + +#[cfg(unix)] +pub fn run_install(dir: Option, force: bool) -> Result<(), String> { + let exe = std::env::current_exe() + .map_err(|e| format!("Could not determine the running binary path: {}", e))?; + + let dirs = match dir { + Some(d) => vec![d], + None => candidate_dirs(), + }; + + let mut last_error = String::from("no candidate bin directory available"); + for dir in &dirs { + match install_symlink(&exe, dir, force) { + Ok(link) => { + println!("Installed: {} -> {}", link.display(), exe.display()); + if !dir_in_path(dir) { + println!( + "Note: {} is not in your PATH. Add it with:\n export PATH=\"{}:$PATH\"", + dir.display(), + dir.display() + ); + } + println!("You can now run 'tabularis' from your terminal."); + return Ok(()); + } + Err(e) => last_error = e, + } + } + Err(format!( + "Could not install the CLI shortcut: {}\nTry: sudo {} install-cli", + last_error, + exe.display() + )) +} + +#[cfg(unix)] +pub(crate) fn install_symlink(exe: &Path, dir: &Path, force: bool) -> Result { + if !dir.exists() { + std::fs::create_dir_all(dir) + .map_err(|e| format!("could not create {}: {}", dir.display(), e))?; + } + + let link = dir.join("tabularis"); + if let Ok(meta) = std::fs::symlink_metadata(&link) { + let already_ours = meta.file_type().is_symlink() + && std::fs::read_link(&link).map(|t| t == exe).unwrap_or(false); + if already_ours { + return Ok(link); + } + if !force { + return Err(format!( + "{} already exists (use --force to replace it)", + link.display() + )); + } + std::fs::remove_file(&link) + .map_err(|e| format!("could not replace {}: {}", link.display(), e))?; + } + + std::os::unix::fs::symlink(exe, &link) + .map_err(|e| format!("could not create {}: {}", link.display(), e))?; + Ok(link) +} + +#[cfg(not(unix))] +pub fn run_install(_dir: Option, _force: bool) -> Result<(), String> { + Err("install-cli is not supported on this platform. \ + Add the directory containing tabularis.exe to your PATH instead." + .to_string()) +} diff --git a/src-tauri/src/cli/install_tests.rs b/src-tauri/src/cli/install_tests.rs new file mode 100644 index 00000000..cc24b475 --- /dev/null +++ b/src-tauri/src/cli/install_tests.rs @@ -0,0 +1,99 @@ +use super::install::install_symlink; +use std::path::{Path, PathBuf}; + +fn fake_exe(dir: &Path) -> PathBuf { + let exe = dir.join("tabularis-binary"); + std::fs::write(&exe, b"#!/bin/sh\n").unwrap(); + exe +} + +#[test] +fn install_symlink_creates_link_to_exe() { + let tmp = tempfile::tempdir().unwrap(); + let exe = fake_exe(tmp.path()); + let bin = tmp.path().join("bin"); + + let link = install_symlink(&exe, &bin, false).unwrap(); + + assert_eq!(link, bin.join("tabularis")); + assert_eq!(std::fs::read_link(&link).unwrap(), exe); +} + +#[test] +fn install_symlink_creates_missing_target_dir() { + let tmp = tempfile::tempdir().unwrap(); + let exe = fake_exe(tmp.path()); + let bin = tmp.path().join("nested/deeper/bin"); + + install_symlink(&exe, &bin, false).unwrap(); + + assert!(bin.join("tabularis").exists()); +} + +#[test] +fn install_symlink_is_idempotent_when_link_already_points_to_exe() { + let tmp = tempfile::tempdir().unwrap(); + let exe = fake_exe(tmp.path()); + let bin = tmp.path().join("bin"); + + install_symlink(&exe, &bin, false).unwrap(); + let link = install_symlink(&exe, &bin, false).unwrap(); + + assert_eq!(std::fs::read_link(&link).unwrap(), exe); +} + +#[test] +fn install_symlink_refuses_foreign_entry_without_force() { + let tmp = tempfile::tempdir().unwrap(); + let exe = fake_exe(tmp.path()); + let bin = tmp.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(bin.join("tabularis"), b"something else").unwrap(); + + let err = install_symlink(&exe, &bin, false).unwrap_err(); + + assert!(err.contains("--force"), "unexpected error: {}", err); + // The foreign file must be untouched. + assert_eq!( + std::fs::read(bin.join("tabularis")).unwrap(), + b"something else" + ); +} + +#[test] +fn install_symlink_force_replaces_foreign_entry() { + let tmp = tempfile::tempdir().unwrap(); + let exe = fake_exe(tmp.path()); + let bin = tmp.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(bin.join("tabularis"), b"something else").unwrap(); + + let link = install_symlink(&exe, &bin, true).unwrap(); + + assert_eq!(std::fs::read_link(&link).unwrap(), exe); +} + +#[test] +fn install_symlink_force_replaces_stale_symlink() { + let tmp = tempfile::tempdir().unwrap(); + let exe = fake_exe(tmp.path()); + let old_exe = fake_exe(&tmp.path().join("old").tap_create()); + let bin = tmp.path().join("bin"); + + install_symlink(&old_exe, &bin, false).unwrap(); + let link = install_symlink(&exe, &bin, true).unwrap(); + + assert_eq!(std::fs::read_link(&link).unwrap(), exe); +} + +/// Tiny helper so the stale-symlink test can create a sibling dir inline. +trait TapCreate { + fn tap_create(self) -> PathBuf; +} + +impl TapCreate for PathBuf { + fn tap_create(self) -> PathBuf { + std::fs::create_dir_all(&self).unwrap(); + self + } +} diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs new file mode 100644 index 00000000..85f4ed37 --- /dev/null +++ b/src-tauri/src/cli/mod.rs @@ -0,0 +1,182 @@ +//! Command-line interface for the Tabularis binary. +//! +//! Keeping this in its own module means `lib.rs` does not have to know about +//! clap. The surface is split across focused submodules: +//! - argument/subcommand definitions and parsing live here, +//! - subcommand execution lives in [`run`], +//! - result formatting (table/json/csv) lives in [`output`], +//! - the interactive SQL shell lives in [`repl`]. + +use clap::{Parser, Subcommand, ValueEnum}; + +pub mod install; +pub mod output; +pub mod repl; +pub mod run; + +#[cfg(test)] +pub mod args_tests; +#[cfg(all(test, unix))] +pub mod install_tests; +#[cfg(test)] +pub mod output_tests; +#[cfg(test)] +pub mod run_tests; + +pub use run::run_command; + +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +pub struct Args { + /// Start in MCP Server mode (Model Context Protocol) + #[arg(long)] + pub mcp: bool, + + /// Enable debug logging (including sqlx queries) + #[arg(long)] + pub debug: bool, + + /// Open a Visual Explain window for a previously-saved EXPLAIN file + /// (Postgres `EXPLAIN (FORMAT JSON)` output). + #[arg(long, value_name = "FILE")] + pub explain: Option, + + /// Terminal subcommand; when present the GUI is never started. + #[command(subcommand)] + pub command: Option, +} + +/// Output format for query results. +#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputFormat { + /// Aligned ASCII table (psql-like) + Table, + /// JSON array of row objects + Json, + /// RFC 4180 CSV with a header row + Csv, +} + +#[derive(Subcommand, Debug)] +pub enum CliCommand { + /// List saved database connections + #[command(visible_alias = "ls")] + Connections { + /// Output as JSON + #[arg(long)] + json: bool, + }, + + /// List databases available on a connection + Databases { + /// Connection ID or name + connection: String, + /// Output as JSON + #[arg(long)] + json: bool, + }, + + /// List schemas on a connection + Schemas { + /// Connection ID or name + connection: String, + /// Database to target (multi-database connections default to the first one) + #[arg(long, short = 'd')] + database: Option, + /// Output as JSON + #[arg(long)] + json: bool, + }, + + /// List tables on a connection + Tables { + /// Connection ID or name + connection: String, + /// Database to target (multi-database connections default to the first one) + #[arg(long, short = 'd')] + database: Option, + /// Schema name (defaults to the driver's default schema) + #[arg(long)] + schema: Option, + /// Output as JSON + #[arg(long)] + json: bool, + }, + + /// Show columns, indexes and foreign keys of a table + Describe { + /// Connection ID or name + connection: String, + /// Table name + table: String, + /// Database to target (multi-database connections default to the first one) + #[arg(long, short = 'd')] + database: Option, + /// Schema name (defaults to the driver's default schema) + #[arg(long)] + schema: Option, + /// Output as JSON + #[arg(long)] + json: bool, + }, + + /// Execute a SQL query, or open an interactive shell when no SQL is given + #[command(visible_alias = "q")] + Query { + /// Connection ID or name + connection: String, + /// SQL to execute. When omitted, opens an interactive shell + /// (or reads the SQL from stdin when piped) + sql: Option, + /// Database to target (multi-database connections default to the + /// first one; changeable inside the shell with \use) + #[arg(long, short = 'd')] + database: Option, + /// Maximum number of rows to return (0 = unlimited) + #[arg(long, default_value_t = 100)] + limit: u32, + /// Output format (changeable inside the shell with \f) + #[arg(long, value_enum, default_value_t = OutputFormat::Table)] + format: OutputFormat, + /// Schema name (defaults to the driver's default schema) + #[arg(long)] + schema: Option, + }, + + /// Install the 'tabularis' command into your PATH + InstallCli { + /// Target bin directory (defaults to /usr/local/bin, then ~/.local/bin) + #[arg(long, value_name = "DIR")] + dir: Option, + /// Replace an existing 'tabularis' entry in the target directory + #[arg(long)] + force: bool, + }, +} + +impl Args { + fn defaults() -> Self { + Self { + mcp: false, + debug: false, + explain: None, + command: None, + } + } +} + +/// Parse the process arguments, with platform-friendly fallback behaviour. +/// +/// - `--help` / `--version` surface as `Err(DisplayHelp|DisplayVersion)` with the +/// formatted message attached; let clap print them and exit cleanly. +/// - Unknown arguments fall back to defaults so that GUI launches (e.g. +/// macOS passing `-psn_*`) still reach the Tauri builder. +/// - Every other parse failure (misspelled subcommand, missing required +/// argument, bad flag value) is a CLI mistake: print clap's error and exit +/// instead of silently opening the GUI. +pub fn parse() -> Args { + Args::try_parse().unwrap_or_else(|err| match err.kind() { + clap::error::ErrorKind::UnknownArgument => Args::defaults(), + _ => err.exit(), + }) +} diff --git a/src-tauri/src/cli/output.rs b/src-tauri/src/cli/output.rs new file mode 100644 index 00000000..777db6b0 --- /dev/null +++ b/src-tauri/src/cli/output.rs @@ -0,0 +1,138 @@ +//! Pure formatting helpers for CLI output: value rendering, aligned ASCII +//! tables, CSV and JSON. No I/O happens here so everything is unit-testable. + +use crate::models::QueryResult; +use serde_json::Value; + +/// Render a single JSON cell value as plain text. Strings are printed raw +/// (no quotes), `null` becomes `NULL`, everything else uses its compact JSON +/// representation. +pub fn format_value(value: &Value) -> String { + match value { + Value::Null => "NULL".to_string(), + Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +/// Convert a `QueryResult`'s rows into plain-text cells. +pub fn result_to_rows(result: &QueryResult) -> Vec> { + result + .rows + .iter() + .map(|row| row.iter().map(format_value).collect()) + .collect() +} + +/// Replace control characters that would break table alignment with visible +/// escapes. +fn sanitize_cell(cell: &str) -> String { + if cell.contains(['\n', '\r', '\t']) { + cell.chars() + .flat_map(|c| match c { + '\r' => "\\r".chars().collect::>(), + '\n' => "\\n".chars().collect(), + '\t' => "\\t".chars().collect(), + other => vec![other], + }) + .collect() + } else { + cell.to_string() + } +} + +/// Render an aligned ASCII table (psql/mysql style) with a header row. +/// +/// ```text +/// +----+-------+ +/// | id | name | +/// +----+-------+ +/// | 1 | Alice | +/// +----+-------+ +/// ``` +pub fn render_table(headers: &[String], rows: &[Vec]) -> String { + let cols = headers.len(); + let sanitized: Vec> = rows + .iter() + .map(|row| { + (0..cols) + .map(|i| sanitize_cell(row.get(i).map(String::as_str).unwrap_or(""))) + .collect() + }) + .collect(); + + let mut widths: Vec = headers.iter().map(|h| h.chars().count()).collect(); + for row in &sanitized { + for (i, cell) in row.iter().enumerate() { + widths[i] = widths[i].max(cell.chars().count()); + } + } + + let separator = { + let mut s = String::from("+"); + for w in &widths { + s.push_str(&"-".repeat(w + 2)); + s.push('+'); + } + s + }; + + let render_row = |cells: &[String]| { + let mut line = String::from("|"); + for (i, w) in widths.iter().enumerate() { + let cell = cells.get(i).map(String::as_str).unwrap_or(""); + let pad = w - cell.chars().count(); + line.push(' '); + line.push_str(cell); + line.push_str(&" ".repeat(pad + 1)); + line.push('|'); + } + line + }; + + let mut out = String::new(); + out.push_str(&separator); + out.push('\n'); + out.push_str(&render_row(headers)); + out.push('\n'); + out.push_str(&separator); + for row in &sanitized { + out.push('\n'); + out.push_str(&render_row(row)); + } + if !sanitized.is_empty() { + out.push('\n'); + out.push_str(&separator); + } + out +} + +/// Render rows as CSV with a header row. +pub fn render_csv(headers: &[String], rows: &[Vec]) -> Result { + let mut writer = csv::Writer::from_writer(Vec::new()); + writer.write_record(headers).map_err(|e| e.to_string())?; + for row in rows { + writer.write_record(row).map_err(|e| e.to_string())?; + } + let bytes = writer.into_inner().map_err(|e| e.to_string())?; + String::from_utf8(bytes).map_err(|e| e.to_string()) +} + +/// Render a query result as a JSON array of `{column: value}` objects, +/// preserving the original JSON values (not their text rendering). +pub fn render_json(result: &QueryResult) -> String { + let objects: Vec = result + .rows + .iter() + .map(|row| { + let map: serde_json::Map = result + .columns + .iter() + .cloned() + .zip(row.iter().cloned()) + .collect(); + Value::Object(map) + }) + .collect(); + serde_json::to_string_pretty(&objects).expect("serializing JSON values cannot fail") +} diff --git a/src-tauri/src/cli/output_tests.rs b/src-tauri/src/cli/output_tests.rs new file mode 100644 index 00000000..461b8ead --- /dev/null +++ b/src-tauri/src/cli/output_tests.rs @@ -0,0 +1,134 @@ +use super::output::{format_value, render_csv, render_json, render_table, result_to_rows}; +use crate::models::QueryResult; +use serde_json::json; + +fn strings(values: &[&str]) -> Vec { + values.iter().map(|s| s.to_string()).collect() +} + +fn sample_result() -> QueryResult { + QueryResult { + columns: strings(&["id", "name"]), + rows: vec![ + vec![json!(1), json!("Alice")], + vec![json!(2), json!(null)], + ], + affected_rows: 0, + truncated: false, + pagination: None, + } +} + +// --- format_value ----------------------------------------------------------- + +#[test] +fn format_value_renders_null_as_uppercase_null() { + assert_eq!(format_value(&json!(null)), "NULL"); +} + +#[test] +fn format_value_renders_strings_without_quotes() { + assert_eq!(format_value(&json!("hello")), "hello"); +} + +#[test] +fn format_value_renders_numbers_and_booleans() { + assert_eq!(format_value(&json!(42)), "42"); + assert_eq!(format_value(&json!(1.5)), "1.5"); + assert_eq!(format_value(&json!(true)), "true"); +} + +#[test] +fn format_value_renders_nested_json_compactly() { + assert_eq!(format_value(&json!({"a": 1})), r#"{"a":1}"#); + assert_eq!(format_value(&json!([1, 2])), "[1,2]"); +} + +// --- result_to_rows --------------------------------------------------------- + +#[test] +fn result_to_rows_maps_every_cell() { + let rows = result_to_rows(&sample_result()); + assert_eq!(rows, vec![strings(&["1", "Alice"]), strings(&["2", "NULL"])]); +} + +// --- render_table ----------------------------------------------------------- + +#[test] +fn render_table_aligns_columns_to_widest_cell() { + let table = render_table( + &strings(&["id", "name"]), + &[strings(&["1", "Alice"]), strings(&["10", "Bo"])], + ); + let expected = "\ ++----+-------+ +| id | name | ++----+-------+ +| 1 | Alice | +| 10 | Bo | ++----+-------+"; + assert_eq!(table, expected); +} + +#[test] +fn render_table_with_no_rows_prints_header_only() { + let table = render_table(&strings(&["id"]), &[]); + let expected = "\ ++----+ +| id | ++----+"; + assert_eq!(table, expected); +} + +#[test] +fn render_table_escapes_newlines_and_tabs() { + let table = render_table(&strings(&["v"]), &[strings(&["a\nb\tc"])]); + assert!(table.contains("a\\nb\\tc")); +} + +#[test] +fn render_table_pads_missing_cells() { + let table = render_table(&strings(&["a", "b"]), &[strings(&["1"])]); + assert!(table.contains("| 1 | |")); +} + +// --- render_csv ------------------------------------------------------------- + +#[test] +fn render_csv_writes_header_and_rows() { + let csv = render_csv( + &strings(&["id", "name"]), + &[strings(&["1", "Alice"])], + ) + .unwrap(); + assert_eq!(csv, "id,name\n1,Alice\n"); +} + +#[test] +fn render_csv_quotes_cells_containing_separators() { + let csv = render_csv(&strings(&["v"]), &[strings(&["a,b"])]).unwrap(); + assert_eq!(csv, "v\n\"a,b\"\n"); +} + +// --- render_json ------------------------------------------------------------ + +#[test] +fn render_json_emits_array_of_row_objects() { + let rendered = render_json(&sample_result()); + let parsed: serde_json::Value = serde_json::from_str(&rendered).unwrap(); + assert_eq!( + parsed, + json!([ + {"id": 1, "name": "Alice"}, + {"id": 2, "name": null}, + ]) + ); +} + +#[test] +fn render_json_with_no_rows_emits_empty_array() { + let mut result = sample_result(); + result.rows.clear(); + let parsed: serde_json::Value = serde_json::from_str(&render_json(&result)).unwrap(); + assert_eq!(parsed, json!([])); +} diff --git a/src-tauri/src/cli/repl.rs b/src-tauri/src/cli/repl.rs new file mode 100644 index 00000000..5900ea73 --- /dev/null +++ b/src-tauri/src/cli/repl.rs @@ -0,0 +1,282 @@ +//! Interactive SQL shell (`tabularis shell `). +//! +//! Reads SQL statements terminated by `;` (multi-line input supported) and +//! psql-style backslash meta commands, executing them against the resolved +//! driver. Line editing and persistent history come from rustyline. + +use super::run::{effective_limit, override_database, print_query_result}; +use super::{output, OutputFormat}; +use crate::headless; +use crate::models::DatabaseSelection; +use rustyline::error::ReadlineError; +use rustyline::DefaultEditor; +use std::time::Instant; + +const HISTORY_FILE: &str = "cli_history.txt"; + +struct ShellState { + format: OutputFormat, + limit: u32, + schema: Option, + /// Include the current database in the prompt — on for multi-database + /// connections and after a `\use` switch. + show_db_in_prompt: bool, +} + +pub async fn run_shell( + connection: &str, + database: Option, + limit: u32, + format: OutputFormat, + schema: Option, +) -> Result<(), String> { + let (conn, mut params, driver) = headless::resolve_db_driver(connection).await?; + let multi_db = conn.params.database.is_multi(); + let switched = database.is_some(); + override_database(&mut params, database); + + // Fail fast with a clear message instead of erroring on the first query. + // `test_connection` (not `ping`): built-in drivers implement `ping` as a + // check on an *existing* pool, which a fresh headless process never has. + driver + .test_connection(¶ms) + .await + .map_err(|e| format!("Could not connect to '{}': {}", conn.name, e))?; + + println!( + "Connected to {} ({} — {})", + conn.name, + conn.params.driver, + params.database.primary() + ); + if multi_db { + println!( + "Databases on this connection: {} (switch with \\use )", + conn.params.database.as_vec().join(", ") + ); + } + println!("End SQL statements with ';'. Type \\? for help, \\q to quit."); + + let mut state = ShellState { + format, + limit, + schema, + show_db_in_prompt: multi_db || switched, + }; + + let mut editor = DefaultEditor::new().map_err(|e| e.to_string())?; + let history_path = crate::paths::get_app_config_dir().join(HISTORY_FILE); + let _ = editor.load_history(&history_path); + + let mut buffer = String::new(); + loop { + let prompt = if buffer.is_empty() { + if state.show_db_in_prompt { + format!("{}:{}> ", conn.name, params.database.primary()) + } else { + format!("{}> ", conn.name) + } + } else { + "... ".to_string() + }; + + match editor.readline(&prompt) { + Ok(line) => { + let trimmed = line.trim(); + if buffer.is_empty() { + if trimmed.is_empty() { + continue; + } + if trimmed.starts_with('\\') + || trimmed.eq_ignore_ascii_case("exit") + || trimmed.eq_ignore_ascii_case("quit") + { + let _ = editor.add_history_entry(trimmed); + if handle_meta(trimmed, &mut state, &mut params, &*driver).await { + break; + } + continue; + } + } + + buffer.push_str(&line); + buffer.push('\n'); + + if buffer.trim_end().ends_with(';') { + let statement = buffer.trim().to_string(); + let _ = editor.add_history_entry(&statement); + buffer.clear(); + + let sql = statement.trim_end_matches(';').trim(); + if sql.is_empty() { + continue; + } + + let start = Instant::now(); + match driver + .execute_query( + ¶ms, + sql, + effective_limit(state.limit), + 1, + state.schema.as_deref(), + ) + .await + { + Ok(result) => print_query_result(&result, state.format, start.elapsed()), + Err(e) => eprintln!("ERROR: {}", e), + } + } + } + // Ctrl-C: drop any half-typed statement, keep the shell alive. + Err(ReadlineError::Interrupted) => { + buffer.clear(); + } + // Ctrl-D: exit. + Err(ReadlineError::Eof) => break, + Err(e) => return Err(e.to_string()), + } + } + + if let Err(e) = editor.save_history(&history_path) { + log::warn!("Failed to save shell history: {}", e); + } + println!("Bye"); + Ok(()) +} + +/// Handle a meta command. Returns `true` when the shell should exit. +async fn handle_meta( + input: &str, + state: &mut ShellState, + params: &mut crate::models::ConnectionParams, + driver: &dyn crate::drivers::driver_trait::DatabaseDriver, +) -> bool { + let mut parts = input.split_whitespace(); + let command = parts.next().unwrap_or(""); + let arg = parts.next(); + + match command { + "\\q" | "exit" | "quit" => return true, + "\\?" | "\\h" | "\\help" => print_help(), + "\\use" => match arg { + Some(db) => { + // Validate the switch on a throwaway copy so a typo'd + // database name leaves the session on the current one. + let mut candidate = params.clone(); + candidate.database = DatabaseSelection::Single(db.to_string()); + match driver.test_connection(&candidate).await { + Ok(()) => { + *params = candidate; + state.show_db_in_prompt = true; + println!("Now using database {}", db); + } + Err(e) => eprintln!("ERROR: cannot switch to {}: {}", db, e), + } + } + None => println!("Current database: {}", params.database.primary()), + }, + "\\l" => match driver.get_databases(params).await { + Ok(names) => print_name_list(&names), + Err(e) => eprintln!("ERROR: {}", e), + }, + "\\dn" => match driver.get_schemas(params).await { + Ok(names) => print_name_list(&names), + Err(e) => eprintln!("ERROR: {}", e), + }, + "\\dt" => match driver.get_tables(params, state.schema.as_deref()).await { + Ok(tables) => { + let names: Vec = tables.into_iter().map(|t| t.name).collect(); + print_name_list(&names); + } + Err(e) => eprintln!("ERROR: {}", e), + }, + "\\d" => match arg { + Some(table) => describe_table(state, params, driver, table).await, + None => eprintln!("Usage: \\d "), + }, + "\\f" => match arg { + Some("table") => state.format = OutputFormat::Table, + Some("json") => state.format = OutputFormat::Json, + Some("csv") => state.format = OutputFormat::Csv, + _ => eprintln!("Usage: \\f "), + }, + "\\limit" => match arg.and_then(|a| a.parse::().ok()) { + Some(n) => { + state.limit = n; + if n == 0 { + println!("Row limit disabled"); + } else { + println!("Row limit set to {}", n); + } + } + None => eprintln!("Usage: \\limit (0 = unlimited)"), + }, + "\\schema" => { + state.schema = arg.map(String::from); + match &state.schema { + Some(s) => println!("Schema set to {}", s), + None => println!("Schema reset to driver default"), + } + } + _ => eprintln!("Unknown command: {} (\\? for help)", command), + } + false +} + +async fn describe_table( + state: &ShellState, + params: &crate::models::ConnectionParams, + driver: &dyn crate::drivers::driver_trait::DatabaseDriver, + table: &str, +) { + let schema = state.schema.as_deref(); + match driver.get_columns(params, table, schema).await { + Ok(columns) => { + let headers = ["COLUMN", "TYPE", "NULLABLE", "PK", "DEFAULT"] + .map(String::from) + .to_vec(); + let rows: Vec> = columns + .iter() + .map(|c| { + vec![ + c.name.clone(), + c.data_type.clone(), + if c.is_nullable { "YES" } else { "NO" }.to_string(), + if c.is_pk { "PK" } else { "" }.to_string(), + c.default_value.clone().unwrap_or_default(), + ] + }) + .collect(); + println!("{}", output::render_table(&headers, &rows)); + } + Err(e) => eprintln!("ERROR: {}", e), + } +} + +fn print_name_list(names: &[String]) { + for name in names { + println!("{}", name); + } + println!("({} found)", names.len()); +} + +fn print_help() { + println!( + "Meta commands: + \\q, exit, quit Quit the shell + \\? Show this help + \\use [db] Switch database, or show the current one + \\l List databases + \\dn List schemas + \\dt List tables (in the current schema) + \\d
Show the columns of a table + \\f Set the output format + \\limit Set the row limit (0 = unlimited) + \\schema [name] Set or reset the current schema + +Any other input is buffered as SQL and executed when a line ends with ';'. +Each statement runs on its own pooled connection, so session state +(SET, BEGIN/COMMIT, temp tables) does not persist between statements." + ); +} diff --git a/src-tauri/src/cli/run.rs b/src-tauri/src/cli/run.rs new file mode 100644 index 00000000..c3213b8c --- /dev/null +++ b/src-tauri/src/cli/run.rs @@ -0,0 +1,338 @@ +//! Execution of the CLI subcommands. Each command resolves the saved +//! connection through [`crate::headless`] (keychain, SSH/K8s tunnels) and +//! talks to the registered driver directly — no Tauri runtime involved. + +use super::output; +use super::{CliCommand, OutputFormat}; +use crate::headless; +use crate::models::{ConnectionParams, DatabaseSelection, QueryResult}; +use serde_json::json; +use std::io::{IsTerminal, Read}; +use std::time::Instant; + +/// Run a CLI subcommand to completion and return the process exit code. +pub async fn run_command(command: CliCommand) -> i32 { + match execute(command).await { + Ok(()) => 0, + Err(e) => { + eprintln!("Error: {}", e); + 1 + } + } +} + +async fn execute(command: CliCommand) -> Result<(), String> { + // `connections` and `install-cli` never talk to a database; everything + // else needs the driver registry populated first (built-ins + enabled + // plugins). + if !matches!( + command, + CliCommand::Connections { .. } | CliCommand::InstallCli { .. } + ) { + headless::register_drivers().await; + } + + match command { + CliCommand::Connections { json } => cmd_connections(json), + CliCommand::Databases { connection, json } => cmd_databases(&connection, json).await, + CliCommand::Schemas { + connection, + database, + json, + } => cmd_schemas(&connection, database, json).await, + CliCommand::Tables { + connection, + database, + schema, + json, + } => cmd_tables(&connection, database, schema.as_deref(), json).await, + CliCommand::Describe { + connection, + table, + database, + schema, + json, + } => cmd_describe(&connection, &table, database, schema.as_deref(), json).await, + CliCommand::Query { + connection, + sql, + database, + limit, + format, + schema, + } => cmd_query(&connection, sql, database, limit, format, schema.as_deref()).await, + CliCommand::InstallCli { dir, force } => super::install::run_install(dir, force), + } +} + +/// Scope the resolved params to one database, mirroring the GUI's +/// per-call `database` override. Multi-database connections resolve to +/// their *first* database otherwise, so without this there is no way to +/// reach the other databases of the connection. +pub(crate) fn override_database(params: &mut ConnectionParams, database: Option) { + if let Some(db) = database { + params.database = DatabaseSelection::Single(db); + } +} + +fn cmd_connections(as_json: bool) -> Result<(), String> { + let config_path = crate::paths::get_app_config_dir().join("connections.json"); + let connections = crate::persistence::load_connections(&config_path)?; + + if as_json { + let list: Vec<_> = connections + .iter() + .map(|c| { + json!({ + "id": c.id, + "name": c.name, + "driver": c.params.driver, + "host": c.params.host, + "port": c.params.port, + "database": c.params.database.to_string(), + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&list).unwrap()); + return Ok(()); + } + + let headers = ["ID", "NAME", "DRIVER", "HOST", "DATABASE"] + .map(String::from) + .to_vec(); + let rows: Vec> = connections + .iter() + .map(|c| { + vec![ + c.id.clone(), + c.name.clone(), + c.params.driver.clone(), + c.params.host.clone().unwrap_or_default(), + c.params.database.to_string(), + ] + }) + .collect(); + println!("{}", output::render_table(&headers, &rows)); + println!("({} connections)", rows.len()); + Ok(()) +} + +/// Print a plain list of names, or a JSON array when `as_json` is set. +fn print_names(names: &[String], as_json: bool) { + if as_json { + println!("{}", serde_json::to_string_pretty(names).unwrap()); + } else { + for name in names { + println!("{}", name); + } + } +} + +async fn cmd_databases(connection: &str, as_json: bool) -> Result<(), String> { + let (_, params, driver) = headless::resolve_db_driver(connection).await?; + let databases = driver.get_databases(¶ms).await?; + print_names(&databases, as_json); + Ok(()) +} + +async fn cmd_schemas( + connection: &str, + database: Option, + as_json: bool, +) -> Result<(), String> { + let (_, mut params, driver) = headless::resolve_db_driver(connection).await?; + override_database(&mut params, database); + let schemas = driver.get_schemas(¶ms).await?; + print_names(&schemas, as_json); + Ok(()) +} + +async fn cmd_tables( + connection: &str, + database: Option, + schema: Option<&str>, + as_json: bool, +) -> Result<(), String> { + let (_, mut params, driver) = headless::resolve_db_driver(connection).await?; + override_database(&mut params, database); + let tables = driver.get_tables(¶ms, schema).await?; + let names: Vec = tables.into_iter().map(|t| t.name).collect(); + print_names(&names, as_json); + Ok(()) +} + +async fn cmd_describe( + connection: &str, + table: &str, + database: Option, + schema: Option<&str>, + as_json: bool, +) -> Result<(), String> { + let (_, mut params, driver) = headless::resolve_db_driver(connection).await?; + override_database(&mut params, database); + + let (columns, foreign_keys, indexes) = tokio::join!( + driver.get_columns(¶ms, table, schema), + driver.get_foreign_keys(¶ms, table, schema), + driver.get_indexes(¶ms, table, schema), + ); + let columns = columns?; + let foreign_keys = foreign_keys?; + let indexes = indexes?; + + if as_json { + let result = json!({ + "table": table, + "columns": columns, + "foreign_keys": foreign_keys, + "indexes": indexes, + }); + println!("{}", serde_json::to_string_pretty(&result).unwrap()); + return Ok(()); + } + + let col_headers = ["COLUMN", "TYPE", "NULLABLE", "PK", "DEFAULT"] + .map(String::from) + .to_vec(); + let col_rows: Vec> = columns + .iter() + .map(|c| { + vec![ + c.name.clone(), + c.data_type.clone(), + if c.is_nullable { "YES" } else { "NO" }.to_string(), + if c.is_pk { "PK" } else { "" }.to_string(), + c.default_value.clone().unwrap_or_default(), + ] + }) + .collect(); + println!("Table: {}", table); + println!("{}", output::render_table(&col_headers, &col_rows)); + + if !indexes.is_empty() { + let idx_headers = ["INDEX", "COLUMN", "UNIQUE", "PRIMARY"] + .map(String::from) + .to_vec(); + let idx_rows: Vec> = indexes + .iter() + .map(|i| { + vec![ + i.name.clone(), + i.column_name.clone(), + if i.is_unique { "YES" } else { "" }.to_string(), + if i.is_primary { "YES" } else { "" }.to_string(), + ] + }) + .collect(); + println!("\nIndexes:"); + println!("{}", output::render_table(&idx_headers, &idx_rows)); + } + + if !foreign_keys.is_empty() { + let fk_headers = ["FOREIGN KEY", "COLUMN", "REFERENCES", "ON DELETE"] + .map(String::from) + .to_vec(); + let fk_rows: Vec> = foreign_keys + .iter() + .map(|fk| { + vec![ + fk.name.clone(), + fk.column_name.clone(), + format!("{}({})", fk.ref_table, fk.ref_column), + fk.on_delete.clone().unwrap_or_default(), + ] + }) + .collect(); + println!("\nForeign keys:"); + println!("{}", output::render_table(&fk_headers, &fk_rows)); + } + + Ok(()) +} + +async fn cmd_query( + connection: &str, + sql: Option, + database: Option, + limit: u32, + format: OutputFormat, + schema: Option<&str>, +) -> Result<(), String> { + let sql = match sql { + Some(s) => s, + // No SQL argument: with an interactive terminal this becomes the + // shell; with piped input the SQL is read from stdin instead. + None if std::io::stdin().is_terminal() => { + return super::repl::run_shell( + connection, + database, + limit, + format, + schema.map(String::from), + ) + .await; + } + None => { + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .map_err(|e| format!("Failed to read SQL from stdin: {}", e))?; + buf + } + }; + let sql = sql.trim(); + if sql.is_empty() { + return Err("No SQL provided (pass it as an argument or pipe it via stdin)".to_string()); + } + + let (_, mut params, driver) = headless::resolve_db_driver(connection).await?; + override_database(&mut params, database); + let start = Instant::now(); + let result = driver + .execute_query(¶ms, sql, effective_limit(limit), 1, schema) + .await?; + print_query_result(&result, format, start.elapsed()); + Ok(()) +} + +/// `--limit 0` means "no limit". +pub(crate) fn effective_limit(limit: u32) -> Option { + if limit == 0 { + None + } else { + Some(limit) + } +} + +/// Print a query result in the requested format. Statements that return no +/// result set (INSERT/UPDATE/DDL) print an `OK` line with the affected-row +/// count instead. +pub(crate) fn print_query_result( + result: &QueryResult, + format: OutputFormat, + elapsed: std::time::Duration, +) { + let ms = elapsed.as_millis(); + + if result.columns.is_empty() { + println!("OK, {} rows affected ({} ms)", result.affected_rows, ms); + return; + } + + match format { + OutputFormat::Table => { + let rows = output::result_to_rows(result); + println!("{}", output::render_table(&result.columns, &rows)); + let truncated = if result.truncated { ", truncated" } else { "" }; + println!("({} rows{} in {} ms)", rows.len(), truncated, ms); + } + OutputFormat::Json => println!("{}", output::render_json(result)), + OutputFormat::Csv => { + let rows = output::result_to_rows(result); + match output::render_csv(&result.columns, &rows) { + Ok(csv) => print!("{}", csv), + Err(e) => eprintln!("Error: failed to render CSV: {}", e), + } + } + } +} diff --git a/src-tauri/src/cli/run_tests.rs b/src-tauri/src/cli/run_tests.rs new file mode 100644 index 00000000..5bd91054 --- /dev/null +++ b/src-tauri/src/cli/run_tests.rs @@ -0,0 +1,52 @@ +use super::run::{effective_limit, override_database}; +use crate::models::{ConnectionParams, DatabaseSelection}; + +// --- effective_limit ---------------------------------------------------------- + +#[test] +fn effective_limit_zero_means_unlimited() { + assert_eq!(effective_limit(0), None); +} + +#[test] +fn effective_limit_passes_positive_values_through() { + assert_eq!(effective_limit(1), Some(1)); + assert_eq!(effective_limit(100), Some(100)); +} + +// --- override_database ---------------------------------------------------------- + +fn multi_db_params() -> ConnectionParams { + ConnectionParams { + driver: "mysql".to_string(), + database: DatabaseSelection::Multiple(vec![ + "first_db".to_string(), + "second_db".to_string(), + ]), + ..Default::default() + } +} + +#[test] +fn override_database_scopes_to_single_database() { + let mut params = multi_db_params(); + override_database(&mut params, Some("second_db".to_string())); + assert_eq!(params.database.primary(), "second_db"); + assert!(!params.database.is_multi()); +} + +#[test] +fn override_database_none_keeps_existing_selection() { + let mut params = multi_db_params(); + override_database(&mut params, None); + assert_eq!(params.database.primary(), "first_db"); + assert!(params.database.is_multi()); +} + +#[test] +fn override_database_allows_databases_outside_the_saved_list() { + // Server permissions decide access, mirroring the GUI's database picker. + let mut params = multi_db_params(); + override_database(&mut params, Some("information_schema".to_string())); + assert_eq!(params.database.primary(), "information_schema"); +} diff --git a/src-tauri/src/headless.rs b/src-tauri/src/headless.rs new file mode 100644 index 00000000..a838fa79 --- /dev/null +++ b/src-tauri/src/headless.rs @@ -0,0 +1,214 @@ +//! Connection resolution and driver registration for headless (non-GUI) +//! entry points: the MCP server (`--mcp`) and the CLI subcommands. +//! +//! These helpers mirror what the Tauri command layer does with an +//! `AppHandle` (keychain passwords, SSH/K8s tunnel expansion, driver +//! registry population) but read everything from disk and the OS keychain +//! directly, so they work in a plain process with no Tauri runtime. + +use crate::commands; +use crate::config; +use crate::credential_cache; +use crate::drivers::driver_trait::DatabaseDriver; +use crate::drivers::registry as driver_registry; +use crate::drivers::{mysql, postgres, sqlite}; +use crate::models::{ConnectionParams, K8sConnection, SavedConnection, SshConnection}; +use crate::paths; +use crate::persistence; +use crate::plugins; +use std::sync::Arc; + +/// Headless equivalent of `expand_ssh_connection_params` — no AppHandle needed. +/// Loads SSH credentials from the config file and keychain directly. +pub async fn expand_ssh_params(params: &ConnectionParams) -> Result { + let mut expanded = params.clone(); + + if !params.ssh_enabled.unwrap_or(false) { + return Ok(expanded); + } + + let ssh_id = match ¶ms.ssh_connection_id { + Some(id) => id.clone(), + None => return Ok(expanded), // legacy inline SSH fields already present + }; + + let ssh_path = paths::get_app_config_dir().join("ssh_connections.json"); + if !ssh_path.exists() { + return Err(format!("SSH connection {} not found", ssh_id)); + } + + let content = tokio::task::spawn_blocking({ + let p = ssh_path.clone(); + move || std::fs::read_to_string(p).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + + let mut ssh: SshConnection = serde_json::from_str::>(&content) + .unwrap_or_default() + .into_iter() + .find(|s| s.id == ssh_id) + .ok_or_else(|| format!("SSH connection {} not found", ssh_id))?; + + if ssh.auth_type.is_none() { + ssh.auth_type = Some( + if ssh + .key_file + .as_ref() + .is_some_and(|k| !k.trim().is_empty()) + { + "ssh_key".to_string() + } else { + "password".to_string() + }, + ); + } + + if ssh.save_in_keychain.unwrap_or(false) { + let cache = Arc::new(credential_cache::CredentialCache::default()); + let id = ssh.id.clone(); + let (pwd_r, pass_r) = tokio::task::spawn_blocking(move || { + let pwd = credential_cache::get_ssh_password_cached(&cache, &id); + let pass = credential_cache::get_ssh_key_passphrase_cached(&cache, &id); + (pwd, pass) + }) + .await + .map_err(|e| e.to_string())?; + + if let Ok(v) = pwd_r { + if !v.trim().is_empty() { + ssh.password = Some(v); + } + } + if let Ok(v) = pass_r { + if !v.trim().is_empty() { + ssh.key_passphrase = Some(v); + } + } + } + + expanded.ssh_host = Some(ssh.host); + expanded.ssh_port = Some(ssh.port); + expanded.ssh_user = Some(ssh.user); + expanded.ssh_password = ssh.password; + expanded.ssh_key_file = ssh.key_file; + expanded.ssh_key_passphrase = ssh.key_passphrase; + + Ok(expanded) +} + +/// Headless equivalent of K8s saved-connection expansion. +pub async fn expand_k8s_params(params: &ConnectionParams) -> Result { + let mut expanded = params.clone(); + + if !params.k8s_enabled.unwrap_or(false) { + return Ok(expanded); + } + + let k8s_id = match ¶ms.k8s_connection_id { + Some(id) => id.clone(), + None => return Ok(expanded), + }; + + let k8s_path = paths::get_app_config_dir().join("k8s_connections.json"); + if !k8s_path.exists() { + return Err(format!("K8s connection {} not found", k8s_id)); + } + + let content = tokio::task::spawn_blocking({ + let p = k8s_path.clone(); + move || std::fs::read_to_string(p).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + + let k8s: K8sConnection = serde_json::from_str::>(&content) + .unwrap_or_default() + .into_iter() + .find(|k| k.id == k8s_id) + .ok_or_else(|| format!("K8s connection {} not found", k8s_id))?; + + expanded.k8s_context = Some(k8s.context); + expanded.k8s_namespace = Some(k8s.namespace); + expanded.k8s_resource_type = Some(k8s.resource_type); + expanded.k8s_resource_name = Some(k8s.resource_name); + expanded.k8s_port = Some(k8s.port); + + Ok(expanded) +} + +/// Look up a saved connection by id or (case-insensitive) name. +pub fn find_connection(conn_id: &str) -> Result { + let config_path = paths::get_app_config_dir().join("connections.json"); + let connections = persistence::load_connections(&config_path)?; + + connections + .into_iter() + .find(|c| c.id == conn_id || c.name.eq_ignore_ascii_case(conn_id)) + .ok_or_else(|| format!("Connection not found: {}", conn_id)) +} + +/// Full headless connection resolution: DB password + SSH/K8s expansion + +/// tunnel setup. +pub async fn resolve_db_params( + conn_id: &str, +) -> Result<(SavedConnection, ConnectionParams), String> { + let mut conn = find_connection(conn_id)?; + + // Load DB password from keychain if it isn't stored inline + if conn.params.save_in_keychain.unwrap_or(false) { + let cache = Arc::new(credential_cache::CredentialCache::default()); + let id = conn.id.clone(); + let pwd = tokio::task::spawn_blocking(move || { + credential_cache::get_db_password_cached(&cache, &id) + }) + .await + .map_err(|e| e.to_string())?; + + if let Ok(p) = pwd { + if !p.trim().is_empty() { + conn.params.password = Some(p); + } + } + } + + let expanded = expand_ssh_params(&conn.params).await?; + let expanded = expand_k8s_params(&expanded).await?; + let db_params = commands::resolve_connection_params(&expanded)?; + Ok((conn, db_params)) +} + +/// Populate the driver registry for a headless process: the three built-in +/// drivers plus any installed plugin drivers, honoring the user's +/// `active_external_drivers` preference. Without this, headless modes can +/// only reach mysql/postgres/sqlite connections — every other driver fails +/// with "Unsupported driver". +pub async fn register_drivers() { + // Required by code paths that go through `sqlx::Any` (e.g. the default + // `test_connection`); the GUI installs these in `run()`, headless + // processes must do it themselves. + sqlx::any::install_default_drivers(); + + driver_registry::register_driver(mysql::MysqlDriver::new()).await; + driver_registry::register_driver(postgres::PostgresDriver::new()).await; + driver_registry::register_driver(sqlite::SqliteDriver::new()).await; + + let app_config = config::load_config_from_disk(); + let plugin_configs = app_config.plugins.unwrap_or_default(); + let enabled_ids = app_config.active_external_drivers; + plugins::manager::load_plugins_with_configs(plugin_configs, enabled_ids.as_deref()).await; +} + +/// Resolve the driver for a saved connection. Returns the connection, the +/// resolved DB params, and the registered driver. Errors with "Unsupported +/// driver" when no driver matches the connection's `driver` id (e.g. the +/// plugin failed to load). +pub async fn resolve_db_driver( + conn_id: &str, +) -> Result<(SavedConnection, ConnectionParams, Arc), String> { + let (conn, db_params) = resolve_db_params(conn_id).await?; + let driver = driver_registry::get_driver(&conn.params.driver) + .await + .ok_or_else(|| format!("Unsupported driver: {}", conn.params.driver))?; + Ok((conn, db_params, driver)) +} diff --git a/src-tauri/src/keychain_utils.rs b/src-tauri/src/keychain_utils.rs index c31a2358..15323db1 100644 --- a/src-tauri/src/keychain_utils.rs +++ b/src-tauri/src/keychain_utils.rs @@ -3,20 +3,20 @@ use keyring::Entry; const SERVICE_NAME: &str = "tabularis"; pub fn set_db_password(connection_id: &str, password: &str) -> Result<(), String> { - println!("[Keychain] Setting DB password for {}", connection_id); + log::info!("[Keychain] Setting DB password for {}", connection_id); let entry = Entry::new(SERVICE_NAME, &format!("{}:db", connection_id)).map_err(|e| e.to_string())?; entry.set_password(password).map_err(|e| { - println!("[Keychain] Error setting password: {}", e); + log::error!("[Keychain] Error setting password: {}", e); e.to_string() }) } pub fn get_db_password(connection_id: &str, connection_name: &str) -> Result { if connection_name.is_empty() { - println!("[Keychain] Getting DB password for {}", connection_id); + log::info!("[Keychain] Getting DB password for {}", connection_id); } else { - println!( + log::info!( "[Keychain] Getting DB password for {} ({})", connection_name, connection_id ); @@ -25,11 +25,11 @@ pub fn get_db_password(connection_id: &str, connection_name: &str) -> Result { - println!("[Keychain] Password found for {}", connection_id); + log::info!("[Keychain] Password found for {}", connection_id); Ok(pwd) } Err(e) => { - println!( + log::error!( "[Keychain] Error getting password for {}: {}", connection_id, e ); @@ -49,20 +49,20 @@ pub fn delete_db_password(connection_id: &str) -> Result<(), String> { } pub fn set_ssh_password(connection_id: &str, password: &str) -> Result<(), String> { - println!("[Keychain] Setting SSH password for {}", connection_id); + log::info!("[Keychain] Setting SSH password for {}", connection_id); let entry = Entry::new(SERVICE_NAME, &format!("{}:ssh", connection_id)).map_err(|e| e.to_string())?; entry.set_password(password).map_err(|e| { - println!("[Keychain] Error setting SSH password: {}", e); + log::error!("[Keychain] Error setting SSH password: {}", e); e.to_string() }) } pub fn get_ssh_password(connection_id: &str, connection_name: &str) -> Result { if connection_name.is_empty() { - println!("[Keychain] Getting SSH password for {}", connection_id); + log::info!("[Keychain] Getting SSH password for {}", connection_id); } else { - println!( + log::info!( "[Keychain] Getting SSH password for {} ({})", connection_name, connection_id ); @@ -71,11 +71,11 @@ pub fn get_ssh_password(connection_id: &str, connection_name: &str) -> Result { - println!("[Keychain] SSH Password found for {}", connection_id); + log::info!("[Keychain] SSH Password found for {}", connection_id); Ok(pwd) } Err(e) => { - println!( + log::error!( "[Keychain] Error getting SSH password for {}: {}", connection_id, e ); @@ -95,14 +95,14 @@ pub fn delete_ssh_password(connection_id: &str) -> Result<(), String> { } pub fn set_ssh_key_passphrase(connection_id: &str, passphrase: &str) -> Result<(), String> { - println!( + log::info!( "[Keychain] Setting SSH key passphrase for {}", connection_id ); let entry = Entry::new(SERVICE_NAME, &format!("{}:ssh_passphrase", connection_id)) .map_err(|e| e.to_string())?; entry.set_password(passphrase).map_err(|e| { - println!("[Keychain] Error setting SSH key passphrase: {}", e); + log::error!("[Keychain] Error setting SSH key passphrase: {}", e); e.to_string() }) } @@ -112,12 +112,12 @@ pub fn get_ssh_key_passphrase( connection_name: &str, ) -> Result { if connection_name.is_empty() { - println!( + log::info!( "[Keychain] Getting SSH key passphrase for {}", connection_id ); } else { - println!( + log::info!( "[Keychain] Getting SSH key passphrase for {} ({})", connection_name, connection_id ); @@ -126,11 +126,11 @@ pub fn get_ssh_key_passphrase( .map_err(|e| e.to_string())?; match entry.get_password() { Ok(pwd) => { - println!("[Keychain] SSH key passphrase found for {}", connection_id); + log::info!("[Keychain] SSH key passphrase found for {}", connection_id); Ok(pwd) } Err(e) => { - println!( + log::error!( "[Keychain] Error getting SSH key passphrase for {}: {}", connection_id, e ); @@ -150,11 +150,11 @@ pub fn delete_ssh_key_passphrase(connection_id: &str) -> Result<(), String> { } pub fn set_ai_key(provider: &str, key: &str) -> Result<(), String> { - println!("[Keychain] Setting AI key for {}", provider); + log::info!("[Keychain] Setting AI key for {}", provider); let entry = Entry::new(SERVICE_NAME, &format!("ai_key:{}", provider)).map_err(|e| e.to_string())?; entry.set_password(key).map_err(|e| { - println!("[Keychain] Error setting AI key: {}", e); + log::error!("[Keychain] Error setting AI key: {}", e); e.to_string() }) } @@ -176,7 +176,7 @@ pub fn get_ai_key(provider: &str) -> Result, String> { Ok(pwd) => Ok(Some(pwd)), Err(keyring::Error::NoEntry) => Ok(None), Err(e) => { - eprintln!("[Keychain] Error getting AI key for {}: {}", provider, e); + log::error!("[Keychain] Error getting AI key for {}: {}", provider, e); Err(e.to_string()) } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9c93fa0c..f5be210b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -31,6 +31,7 @@ pub mod explain_import_tests; pub mod export; #[cfg(test)] pub mod export_import_tests; +pub mod headless; pub mod health_check; pub mod heartbeat; #[cfg(test)] @@ -122,6 +123,21 @@ pub fn run() { let args = cli::parse(); + if let Some(command) = args.command { + // Terminal mode: run the subcommand and exit without ever touching + // the Tauri builder. The custom logger writes to stderr only, so + // stdout stays clean for piping (csv/json output). + let level = if args.debug { + log::LevelFilter::Debug + } else { + log::LevelFilter::Warn + }; + init_logger(create_log_buffer(1000), level); + let rt = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime"); + let exit_code = rt.block_on(cli::run_command(command)); + std::process::exit(exit_code); + } + if args.mcp { // Initialize the logger so plugin-loading and driver RPC errors (which // use the `log` crate) are visible. The custom logger writes to stderr diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index 687062b2..cbeaf3b7 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -1,20 +1,16 @@ use crate::ai_activity::{self, AiActivityEvent}; use crate::ai_approval::{self, PendingApproval, PollOutcome}; -use crate::commands; use crate::config::{ self, AppConfig, DEFAULT_AI_AUDIT_ENABLED, DEFAULT_AI_AUDIT_MAX_ENTRIES, DEFAULT_AI_SESSION_GAP_MINUTES, DEFAULT_MCP_APPROVAL_MODE, DEFAULT_MCP_APPROVAL_TIMEOUT_SECONDS, DEFAULT_MCP_PREFLIGHT_EXPLAIN, }; -use crate::credential_cache; use crate::drivers::driver_trait::DatabaseDriver; -use crate::drivers::registry as driver_registry; -use crate::drivers::{mysql, postgres, sqlite}; +use crate::headless; use crate::heartbeat; -use crate::models::{ConnectionParams, K8sConnection, SshConnection}; +use crate::models::ConnectionParams; use crate::paths; use crate::persistence; -use crate::plugins; use serde_json::{json, Value}; use std::io::{self, BufRead, Write}; use std::sync::Arc; @@ -55,236 +51,14 @@ impl CallAudit { } } -/// MCP-mode equivalent of `expand_ssh_connection_params` — no AppHandle needed. -/// Loads SSH credentials from the config file and keychain directly. -async fn expand_ssh_params_for_mcp( - params: &ConnectionParams, -) -> Result { - let mut expanded = params.clone(); - - if !params.ssh_enabled.unwrap_or(false) { - return Ok(expanded); - } - - let ssh_id = match ¶ms.ssh_connection_id { - Some(id) => id.clone(), - None => return Ok(expanded), // legacy inline SSH fields already present - }; - - let ssh_path = paths::get_app_config_dir().join("ssh_connections.json"); - if !ssh_path.exists() { - return Err(JsonRpcError { - code: -32000, - message: format!("SSH connection {} not found", ssh_id), - data: None, - }); - } - - let content = tokio::task::spawn_blocking({ - let p = ssh_path.clone(); - move || std::fs::read_to_string(p).map_err(|e| e.to_string()) - }) - .await - .map_err(|e| JsonRpcError { +/// Wrap a headless-resolution error into the JSON-RPC error shape used by +/// every MCP tool. +fn internal_error(message: String) -> JsonRpcError { + JsonRpcError { code: -32000, - message: e.to_string(), + message, data: None, - })? - .map_err(|e| JsonRpcError { - code: -32000, - message: e, - data: None, - })?; - - let mut ssh: SshConnection = serde_json::from_str::>(&content) - .unwrap_or_default() - .into_iter() - .find(|s| s.id == ssh_id) - .ok_or_else(|| JsonRpcError { - code: -32000, - message: format!("SSH connection {} not found", ssh_id), - data: None, - })?; - - if ssh.auth_type.is_none() { - ssh.auth_type = Some( - if ssh - .key_file - .as_ref() - .map_or(false, |k| !k.trim().is_empty()) - { - "ssh_key".to_string() - } else { - "password".to_string() - }, - ); - } - - if ssh.save_in_keychain.unwrap_or(false) { - let cache = std::sync::Arc::new(credential_cache::CredentialCache::default()); - let id = ssh.id.clone(); - let (pwd_r, pass_r) = tokio::task::spawn_blocking(move || { - let pwd = credential_cache::get_ssh_password_cached(&cache, &id); - let pass = credential_cache::get_ssh_key_passphrase_cached(&cache, &id); - (pwd, pass) - }) - .await - .map_err(|e| JsonRpcError { - code: -32000, - message: e.to_string(), - data: None, - })?; - - if let Ok(v) = pwd_r { - if !v.trim().is_empty() { - ssh.password = Some(v); - } - } - if let Ok(v) = pass_r { - if !v.trim().is_empty() { - ssh.key_passphrase = Some(v); - } - } } - - expanded.ssh_host = Some(ssh.host); - expanded.ssh_port = Some(ssh.port); - expanded.ssh_user = Some(ssh.user); - expanded.ssh_password = ssh.password; - expanded.ssh_key_file = ssh.key_file; - expanded.ssh_key_passphrase = ssh.key_passphrase; - - Ok(expanded) -} - -/// MCP-mode equivalent of K8s saved-connection expansion. -async fn expand_k8s_params_for_mcp( - params: &ConnectionParams, -) -> Result { - let mut expanded = params.clone(); - - if !params.k8s_enabled.unwrap_or(false) { - return Ok(expanded); - } - - let k8s_id = match ¶ms.k8s_connection_id { - Some(id) => id.clone(), - None => return Ok(expanded), - }; - - let k8s_path = paths::get_app_config_dir().join("k8s_connections.json"); - if !k8s_path.exists() { - return Err(JsonRpcError { - code: -32000, - message: format!("K8s connection {} not found", k8s_id), - data: None, - }); - } - - let content = tokio::task::spawn_blocking({ - let p = k8s_path.clone(); - move || std::fs::read_to_string(p).map_err(|e| e.to_string()) - }) - .await - .map_err(|e| JsonRpcError { - code: -32000, - message: e.to_string(), - data: None, - })? - .map_err(|e| JsonRpcError { - code: -32000, - message: e, - data: None, - })?; - - let k8s: K8sConnection = serde_json::from_str::>(&content) - .unwrap_or_default() - .into_iter() - .find(|k| k.id == k8s_id) - .ok_or_else(|| JsonRpcError { - code: -32000, - message: format!("K8s connection {} not found", k8s_id), - data: None, - })?; - - expanded.k8s_context = Some(k8s.context); - expanded.k8s_namespace = Some(k8s.namespace); - expanded.k8s_resource_type = Some(k8s.resource_type); - expanded.k8s_resource_name = Some(k8s.resource_name); - expanded.k8s_port = Some(k8s.port); - - Ok(expanded) -} - -fn find_connection(conn_id: &str) -> Result { - let config_path = paths::get_app_config_dir().join("connections.json"); - let connections = persistence::load_connections(&config_path).map_err(|e| JsonRpcError { - code: -32000, - message: e, - data: None, - })?; - - connections - .into_iter() - .find(|c| c.id == conn_id || c.name.eq_ignore_ascii_case(conn_id)) - .ok_or_else(|| JsonRpcError { - code: -32000, - message: format!("Connection not found: {}", conn_id), - data: None, - }) -} - -/// Full connection resolution for MCP: DB password + SSH expansion + tunnel setup. -async fn resolve_db_params( - conn_id: &str, -) -> Result<(crate::models::SavedConnection, ConnectionParams), JsonRpcError> { - let mut conn = find_connection(conn_id)?; - - // Load DB password from keychain if it isn't stored inline - if conn.params.save_in_keychain.unwrap_or(false) { - let cache = std::sync::Arc::new(credential_cache::CredentialCache::default()); - let id = conn.id.clone(); - let pwd = tokio::task::spawn_blocking(move || { - credential_cache::get_db_password_cached(&cache, &id) - }) - .await - .map_err(|e| JsonRpcError { - code: -32000, - message: e.to_string(), - data: None, - })?; - - if let Ok(p) = pwd { - if !p.trim().is_empty() { - conn.params.password = Some(p); - } - } - } - - let expanded = expand_ssh_params_for_mcp(&conn.params).await?; - let expanded = expand_k8s_params_for_mcp(&expanded).await?; - let db_params = commands::resolve_connection_params(&expanded).map_err(|e| JsonRpcError { - code: -32000, - message: e, - data: None, - })?; - Ok((conn, db_params)) -} - -/// Populate the driver registry for the standalone MCP subprocess: the three -/// built-in drivers plus any installed plugin drivers, honoring the user's -/// `active_external_drivers` preference. Without this, MCP can only reach -/// mysql/postgres/sqlite connections — every other driver fails with -/// "Unsupported driver". -async fn register_drivers_for_mcp() { - driver_registry::register_driver(mysql::MysqlDriver::new()).await; - driver_registry::register_driver(postgres::PostgresDriver::new()).await; - driver_registry::register_driver(sqlite::SqliteDriver::new()).await; - - let app_config = config::load_config_from_disk(); - let plugin_configs = app_config.plugins.unwrap_or_default(); - let enabled_ids = app_config.active_external_drivers; - plugins::manager::load_plugins_with_configs(plugin_configs, enabled_ids.as_deref()).await; } /// Resolve the driver for an MCP-known connection. Returns the connection, @@ -301,21 +75,15 @@ async fn resolve_db_driver( ), JsonRpcError, > { - let (conn, db_params) = resolve_db_params(conn_id).await?; - let driver = driver_registry::get_driver(&conn.params.driver) + headless::resolve_db_driver(conn_id) .await - .ok_or_else(|| JsonRpcError { - code: -32000, - message: format!("Unsupported driver: {}", conn.params.driver), - data: None, - })?; - Ok((conn, db_params, driver)) + .map_err(internal_error) } pub async fn run_mcp_server() { eprintln!("[MCP] Starting Tabularis MCP Server..."); - register_drivers_for_mcp().await; + headless::register_drivers().await; let stdin = io::stdin(); let mut stdout = io::stdout(); From 9b1517a756637b8f4130ac15c54f1c175a8edcf4 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Wed, 10 Jun 2026 15:34:04 +0200 Subject: [PATCH 2/4] fix(cli): sanitize control characters in CLI output --- src-tauri/src/cli/output.rs | 40 ++++++++++++++++------------ src-tauri/src/cli/output_tests.rs | 43 ++++++++++++++++++++++++++++++- src-tauri/src/cli/repl.rs | 20 +++++++++----- src-tauri/src/cli/run.rs | 21 ++++++++++++--- 4 files changed, 96 insertions(+), 28 deletions(-) diff --git a/src-tauri/src/cli/output.rs b/src-tauri/src/cli/output.rs index 777db6b0..4506a8a7 100644 --- a/src-tauri/src/cli/output.rs +++ b/src-tauri/src/cli/output.rs @@ -24,21 +24,26 @@ pub fn result_to_rows(result: &QueryResult) -> Vec> { .collect() } -/// Replace control characters that would break table alignment with visible -/// escapes. -fn sanitize_cell(cell: &str) -> String { - if cell.contains(['\n', '\r', '\t']) { - cell.chars() - .flat_map(|c| match c { - '\r' => "\\r".chars().collect::>(), - '\n' => "\\n".chars().collect(), - '\t' => "\\t".chars().collect(), - other => vec![other], - }) - .collect() - } else { - cell.to_string() +/// Replace every control character (C0 including ESC, DEL, C1) with a visible +/// escape. Database-sourced text reaches the terminal through here, so this +/// is what stops crafted cell values or identifiers from injecting ANSI +/// escape sequences (cursor movement, OSC 52 clipboard writes, title changes) +/// — not just a cosmetic alignment fix. +pub fn sanitize_text(text: &str) -> String { + if !text.chars().any(char::is_control) { + return text.to_string(); } + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match c { + '\r' => out.push_str("\\r"), + '\n' => out.push_str("\\n"), + '\t' => out.push_str("\\t"), + c if c.is_control() => out.extend(c.escape_unicode()), + c => out.push(c), + } + } + out } /// Render an aligned ASCII table (psql/mysql style) with a header row. @@ -52,11 +57,14 @@ fn sanitize_cell(cell: &str) -> String { /// ``` pub fn render_table(headers: &[String], rows: &[Vec]) -> String { let cols = headers.len(); + // Headers are database-sourced too (column names), so they get the same + // sanitization as cells. + let headers: Vec = headers.iter().map(|h| sanitize_text(h)).collect(); let sanitized: Vec> = rows .iter() .map(|row| { (0..cols) - .map(|i| sanitize_cell(row.get(i).map(String::as_str).unwrap_or(""))) + .map(|i| sanitize_text(row.get(i).map(String::as_str).unwrap_or(""))) .collect() }) .collect(); @@ -93,7 +101,7 @@ pub fn render_table(headers: &[String], rows: &[Vec]) -> String { let mut out = String::new(); out.push_str(&separator); out.push('\n'); - out.push_str(&render_row(headers)); + out.push_str(&render_row(&headers)); out.push('\n'); out.push_str(&separator); for row in &sanitized { diff --git a/src-tauri/src/cli/output_tests.rs b/src-tauri/src/cli/output_tests.rs index 461b8ead..1757f387 100644 --- a/src-tauri/src/cli/output_tests.rs +++ b/src-tauri/src/cli/output_tests.rs @@ -1,4 +1,6 @@ -use super::output::{format_value, render_csv, render_json, render_table, result_to_rows}; +use super::output::{ + format_value, render_csv, render_json, render_table, result_to_rows, sanitize_text, +}; use crate::models::QueryResult; use serde_json::json; @@ -52,6 +54,34 @@ fn result_to_rows_maps_every_cell() { assert_eq!(rows, vec![strings(&["1", "Alice"]), strings(&["2", "NULL"])]); } +// --- sanitize_text ---------------------------------------------------------- + +#[test] +fn sanitize_text_leaves_plain_text_untouched() { + assert_eq!(sanitize_text("hello world é 日本"), "hello world é 日本"); +} + +#[test] +fn sanitize_text_escapes_newlines_carriage_returns_and_tabs() { + assert_eq!(sanitize_text("a\nb\rc\td"), "a\\nb\\rc\\td"); +} + +#[test] +fn sanitize_text_escapes_ansi_escape_sequences() { + // OSC 52 clipboard write: ESC ] 52 ; c ; BEL + assert_eq!( + sanitize_text("\x1b]52;c;ZWNobyBwd25lZA==\x07"), + "\\u{1b}]52;c;ZWNobyBwd25lZA==\\u{7}" + ); +} + +#[test] +fn sanitize_text_escapes_c1_control_characters() { + // U+009B is CSI, the single-character form of ESC [ + assert_eq!(sanitize_text("a\u{9b}31mb"), "a\\u{9b}31mb"); + assert_eq!(sanitize_text("del\u{7f}"), "del\\u{7f}"); +} + // --- render_table ----------------------------------------------------------- #[test] @@ -86,6 +116,17 @@ fn render_table_escapes_newlines_and_tabs() { assert!(table.contains("a\\nb\\tc")); } +#[test] +fn render_table_escapes_control_characters_in_cells_and_headers() { + let table = render_table( + &strings(&["na\x1b[2Jme"]), + &[strings(&["\x1b]0;owned\x07"])], + ); + assert!(!table.contains('\x1b')); + assert!(table.contains("na\\u{1b}[2Jme")); + assert!(table.contains("\\u{1b}]0;owned\\u{7}")); +} + #[test] fn render_table_pads_missing_cells() { let table = render_table(&strings(&["a", "b"]), &[strings(&["1"])]); diff --git a/src-tauri/src/cli/repl.rs b/src-tauri/src/cli/repl.rs index 5900ea73..e62e4399 100644 --- a/src-tauri/src/cli/repl.rs +++ b/src-tauri/src/cli/repl.rs @@ -124,7 +124,7 @@ pub async fn run_shell( .await { Ok(result) => print_query_result(&result, state.format, start.elapsed()), - Err(e) => eprintln!("ERROR: {}", e), + Err(e) => print_error(&e), } } } @@ -171,25 +171,25 @@ async fn handle_meta( state.show_db_in_prompt = true; println!("Now using database {}", db); } - Err(e) => eprintln!("ERROR: cannot switch to {}: {}", db, e), + Err(e) => print_error(&format!("cannot switch to {}: {}", db, e)), } } None => println!("Current database: {}", params.database.primary()), }, "\\l" => match driver.get_databases(params).await { Ok(names) => print_name_list(&names), - Err(e) => eprintln!("ERROR: {}", e), + Err(e) => print_error(&e), }, "\\dn" => match driver.get_schemas(params).await { Ok(names) => print_name_list(&names), - Err(e) => eprintln!("ERROR: {}", e), + Err(e) => print_error(&e), }, "\\dt" => match driver.get_tables(params, state.schema.as_deref()).await { Ok(tables) => { let names: Vec = tables.into_iter().map(|t| t.name).collect(); print_name_list(&names); } - Err(e) => eprintln!("ERROR: {}", e), + Err(e) => print_error(&e), }, "\\d" => match arg { Some(table) => describe_table(state, params, driver, table).await, @@ -250,17 +250,23 @@ async fn describe_table( .collect(); println!("{}", output::render_table(&headers, &rows)); } - Err(e) => eprintln!("ERROR: {}", e), + Err(e) => print_error(&e), } } fn print_name_list(names: &[String]) { for name in names { - println!("{}", name); + println!("{}", output::sanitize_text(name)); } println!("({} found)", names.len()); } +/// Print a driver/server error. The message can embed server-controlled text, +/// so it goes through the same control-character sanitization as query output. +fn print_error(error: &str) { + eprintln!("ERROR: {}", output::sanitize_text(error)); +} + fn print_help() { println!( "Meta commands: diff --git a/src-tauri/src/cli/run.rs b/src-tauri/src/cli/run.rs index c3213b8c..5de27cd8 100644 --- a/src-tauri/src/cli/run.rs +++ b/src-tauri/src/cli/run.rs @@ -15,7 +15,9 @@ pub async fn run_command(command: CliCommand) -> i32 { match execute(command).await { Ok(()) => 0, Err(e) => { - eprintln!("Error: {}", e); + // Error strings can embed server-controlled text; strip control + // characters before they reach the terminal. + eprintln!("Error: {}", output::sanitize_text(&e)); 1 } } @@ -123,7 +125,7 @@ fn print_names(names: &[String], as_json: bool) { println!("{}", serde_json::to_string_pretty(names).unwrap()); } else { for name in names { - println!("{}", name); + println!("{}", output::sanitize_text(name)); } } } @@ -328,8 +330,19 @@ pub(crate) fn print_query_result( } OutputFormat::Json => println!("{}", output::render_json(result)), OutputFormat::Csv => { - let rows = output::result_to_rows(result); - match output::render_csv(&result.columns, &rows) { + let mut headers = result.columns.clone(); + let mut rows = output::result_to_rows(result); + // On a TTY, CSV is read by a human: strip control characters so + // crafted cells cannot inject escape sequences. When piped, keep + // the data byte-exact for downstream tools. + if std::io::stdout().is_terminal() { + headers = headers.iter().map(|h| output::sanitize_text(h)).collect(); + rows = rows + .iter() + .map(|row| row.iter().map(|c| output::sanitize_text(c)).collect()) + .collect(); + } + match output::render_csv(&headers, &rows) { Ok(csv) => print!("{}", csv), Err(e) => eprintln!("Error: failed to render CSV: {}", e), } From 8b8a17a4d6aa1662f3521354b251b7742bfe7033 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Wed, 10 Jun 2026 15:50:16 +0200 Subject: [PATCH 3/4] feat(settings): manage the terminal CLI shortcut from the app (macOS) Add a "Command Line" section to Settings > General to install, remove and reinstall the 'tabularis' command without leaving the GUI, mirroring VSCode's "Install 'code' command in PATH". - Backend: get_cli_install_status / install_cli_shortcut / remove_cli_shortcut Tauri commands on top of cli::install, which now exposes structured state (installed, link path, in PATH, removable). - Status detection recognizes both symlinks created by install-cli and the binary itself already reachable via PATH; removal only ever deletes a symlink that resolves to the running binary, never foreign entries or package-manager installs. - The UI shows the installed path, a copyable PATH export hint when the bin dir is not in PATH, and offers force-replace only after a foreign-entry conflict. - GUI management is gated to macOS, where the binary lives at a stable path inside the .app bundle: on Linux the binary is either already in PATH or at an ephemeral AppImage/Flatpak path, so the section is hidden there. The install-cli subcommand stays available on all Unix. - settings.cli.* strings added to all 8 locales; unit tests for the new Rust helpers and the TS utils. --- src-tauri/src/cli/install.rs | 199 ++++++++++++++++-- src-tauri/src/cli/install_tests.rs | 86 +++++++- src-tauri/src/commands.rs | 20 ++ src-tauri/src/lib.rs | 4 + src/components/settings/CliInstallSection.tsx | 149 +++++++++++++ src/components/settings/GeneralTab.tsx | 3 + src/i18n/locales/de.json | 10 + src/i18n/locales/en.json | 10 + src/i18n/locales/es.json | 10 + src/i18n/locales/fr.json | 10 + src/i18n/locales/it.json | 10 + src/i18n/locales/ja.json | 10 + src/i18n/locales/ru.json | 10 + src/i18n/locales/zh.json | 10 + src/utils/cli.ts | 31 +++ tests/utils/cli.test.ts | 45 ++++ 16 files changed, 597 insertions(+), 20 deletions(-) create mode 100644 src/components/settings/CliInstallSection.tsx create mode 100644 src/utils/cli.ts create mode 100644 tests/utils/cli.test.ts diff --git a/src-tauri/src/cli/install.rs b/src-tauri/src/cli/install.rs index ca91a1e0..77a47b3a 100644 --- a/src-tauri/src/cli/install.rs +++ b/src-tauri/src/cli/install.rs @@ -7,6 +7,25 @@ use std::path::{Path, PathBuf}; +/// Install state of the `tabularis` terminal command, shared between the +/// `install-cli` subcommand and the GUI Settings page. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CliInstallStatus { + /// Managing the shortcut from the GUI is macOS-only: there the binary + /// lives at a stable path inside the .app bundle and is never in PATH. + /// On Linux the binary is either already in PATH (package manager) or at + /// an ephemeral path (AppImage/Flatpak), so the GUI hides the section. + pub supported: bool, + pub installed: bool, + pub link_path: Option, + /// Whether the directory containing the link is in `$PATH`. + pub in_path: bool, + /// True when the entry is a symlink we created and can delete, false for + /// the binary itself (e.g. a package-manager install). + pub removable: bool, +} + /// Candidate bin directories, in order of preference. `/usr/local/bin` is /// already in PATH on macOS but usually needs elevated permissions; /// `~/.local/bin` always works but may need a PATH addition. @@ -35,29 +54,148 @@ pub fn run_install(dir: Option, force: bool) -> Result<(), String> { None => candidate_dirs(), }; - let mut last_error = String::from("no candidate bin directory available"); - for dir in &dirs { - match install_symlink(&exe, dir, force) { - Ok(link) => { - println!("Installed: {} -> {}", link.display(), exe.display()); - if !dir_in_path(dir) { - println!( - "Note: {} is not in your PATH. Add it with:\n export PATH=\"{}:$PATH\"", - dir.display(), - dir.display() - ); - } - println!("You can now run 'tabularis' from your terminal."); - return Ok(()); + match install_to_first(&exe, &dirs, force) { + Ok(link) => { + println!("Installed: {} -> {}", link.display(), exe.display()); + let dir = link.parent().unwrap_or(Path::new("")); + if !dir_in_path(dir) { + println!( + "Note: {} is not in your PATH. Add it with:\n export PATH=\"{}:$PATH\"", + dir.display(), + dir.display() + ); } + println!("You can now run 'tabularis' from your terminal."); + Ok(()) + } + Err(last_error) => Err(format!( + "Could not install the CLI shortcut: {}\nTry: sudo {} install-cli", + last_error, + exe.display() + )), + } +} + +/// Try the directories in order and return the first successful link, or the +/// last error when every candidate fails. +#[cfg(unix)] +fn install_to_first(exe: &Path, dirs: &[PathBuf], force: bool) -> Result { + let mut last_error = String::from("no candidate bin directory available"); + for dir in dirs { + match install_symlink(exe, dir, force) { + Ok(link) => return Ok(link), Err(e) => last_error = e, } } - Err(format!( - "Could not install the CLI shortcut: {}\nTry: sudo {} install-cli", - last_error, - exe.display() - )) + Err(last_error) +} + +/// Find an existing `tabularis` entry in `dirs` that resolves to `exe` — a +/// symlink created by a previous install, or the binary itself when a package +/// manager already placed it in PATH. +// Compiled for unix tests too so the helper stays covered on Linux dev machines. +#[cfg(any(target_os = "macos", all(test, unix)))] +pub(crate) fn find_link_in_dirs(exe: &Path, dirs: &[PathBuf]) -> Option { + let exe = std::fs::canonicalize(exe).ok()?; + dirs.iter() + .map(|dir| dir.join("tabularis")) + .find(|link| std::fs::canonicalize(link).is_ok_and(|target| target == exe)) +} + +#[cfg(target_os = "macos")] +fn status_for_link(link: PathBuf) -> CliInstallStatus { + let in_path = link.parent().map(dir_in_path).unwrap_or(false); + let removable = std::fs::symlink_metadata(&link) + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false); + CliInstallStatus { + supported: true, + installed: true, + link_path: Some(link.display().to_string()), + in_path, + removable, + } +} + +/// Directories scanned when looking for an existing install: the candidates +/// plus every `$PATH` entry, so custom `--dir` installs and package-manager +/// installs are recognized too. +#[cfg(target_os = "macos")] +fn search_dirs() -> Vec { + let mut dirs = candidate_dirs(); + if let Some(path) = std::env::var_os("PATH") { + dirs.extend(std::env::split_paths(&path)); + } + dirs +} + +/// Current install state, for the GUI Settings page. +#[cfg(target_os = "macos")] +pub fn install_status() -> CliInstallStatus { + let not_installed = CliInstallStatus { + supported: true, + installed: false, + link_path: None, + in_path: false, + removable: false, + }; + let Ok(exe) = std::env::current_exe() else { + return not_installed; + }; + match find_link_in_dirs(&exe, &search_dirs()) { + Some(link) => status_for_link(link), + None => not_installed, + } +} + +/// Install into the first writable candidate dir, for the GUI Settings page. +/// Same behaviour as `install-cli` without `--dir`, but returns structured +/// state instead of printing. +#[cfg(target_os = "macos")] +pub fn install_from_gui(force: bool) -> Result { + let exe = std::env::current_exe() + .map_err(|e| format!("Could not determine the running binary path: {}", e))?; + let link = install_to_first(&exe, &candidate_dirs(), force)?; + Ok(status_for_link(link)) +} + +/// Remove `link` only when it is a symlink resolving to `exe`; foreign +/// entries and the binary itself are never touched. +// Compiled for unix tests too so the helper stays covered on Linux dev machines. +#[cfg(any(target_os = "macos", all(test, unix)))] +pub(crate) fn remove_symlink(exe: &Path, link: &Path) -> Result<(), String> { + let meta = std::fs::symlink_metadata(link) + .map_err(|e| format!("could not inspect {}: {}", link.display(), e))?; + if !meta.file_type().is_symlink() { + return Err(format!( + "{} is not a symlink created by Tabularis; refusing to remove it", + link.display() + )); + } + let exe = std::fs::canonicalize(exe) + .map_err(|e| format!("could not resolve {}: {}", exe.display(), e))?; + let target = std::fs::canonicalize(link) + .map_err(|e| format!("could not resolve {}: {}", link.display(), e))?; + if target != exe { + return Err(format!( + "{} does not point to this binary; refusing to remove it", + link.display() + )); + } + std::fs::remove_file(link) + .map_err(|e| format!("could not remove {}: {}", link.display(), e)) +} + +/// Remove the installed shortcut, for the GUI Settings page. Re-scans +/// afterwards so a second copy in another directory keeps being reported. +#[cfg(target_os = "macos")] +pub fn uninstall_from_gui() -> Result { + let exe = std::env::current_exe() + .map_err(|e| format!("Could not determine the running binary path: {}", e))?; + if let Some(link) = find_link_in_dirs(&exe, &search_dirs()) { + remove_symlink(&exe, &link)?; + } + Ok(install_status()) } #[cfg(unix)] @@ -95,3 +233,26 @@ pub fn run_install(_dir: Option, _force: bool) -> Result<(), String> { Add the directory containing tabularis.exe to your PATH instead." .to_string()) } + +#[cfg(not(target_os = "macos"))] +pub fn install_status() -> CliInstallStatus { + CliInstallStatus { + supported: false, + installed: false, + link_path: None, + in_path: false, + removable: false, + } +} + +#[cfg(not(target_os = "macos"))] +pub fn install_from_gui(_force: bool) -> Result { + Err("Managing the CLI shortcut from the app is only supported on macOS. \ + Use 'tabularis install-cli' from a terminal instead." + .to_string()) +} + +#[cfg(not(target_os = "macos"))] +pub fn uninstall_from_gui() -> Result { + Err("Managing the CLI shortcut from the app is only supported on macOS.".to_string()) +} diff --git a/src-tauri/src/cli/install_tests.rs b/src-tauri/src/cli/install_tests.rs index cc24b475..4db2ab1a 100644 --- a/src-tauri/src/cli/install_tests.rs +++ b/src-tauri/src/cli/install_tests.rs @@ -1,4 +1,4 @@ -use super::install::install_symlink; +use super::install::{find_link_in_dirs, install_symlink, remove_symlink}; use std::path::{Path, PathBuf}; fn fake_exe(dir: &Path) -> PathBuf { @@ -86,6 +86,90 @@ fn install_symlink_force_replaces_stale_symlink() { assert_eq!(std::fs::read_link(&link).unwrap(), exe); } +#[test] +fn find_link_in_dirs_finds_symlink_pointing_to_exe() { + let tmp = tempfile::tempdir().unwrap(); + let exe = fake_exe(tmp.path()); + let bin = tmp.path().join("bin"); + install_symlink(&exe, &bin, false).unwrap(); + + let found = find_link_in_dirs(&exe, &[tmp.path().join("empty"), bin.clone()]); + + assert_eq!(found, Some(bin.join("tabularis"))); +} + +#[test] +fn find_link_in_dirs_finds_exe_itself_when_named_tabularis() { + let tmp = tempfile::tempdir().unwrap(); + let bin = tmp.path().join("bin").tap_create(); + let exe = bin.join("tabularis"); + std::fs::write(&exe, b"#!/bin/sh\n").unwrap(); + + assert_eq!(find_link_in_dirs(&exe, &[bin.clone()]), Some(exe)); +} + +#[test] +fn find_link_in_dirs_ignores_foreign_entries() { + let tmp = tempfile::tempdir().unwrap(); + let exe = fake_exe(tmp.path()); + let bin = tmp.path().join("bin").tap_create(); + std::fs::write(bin.join("tabularis"), b"something else").unwrap(); + + assert_eq!(find_link_in_dirs(&exe, &[bin]), None); +} + +#[test] +fn find_link_in_dirs_returns_none_for_missing_dirs() { + let tmp = tempfile::tempdir().unwrap(); + let exe = fake_exe(tmp.path()); + + assert_eq!(find_link_in_dirs(&exe, &[tmp.path().join("nope")]), None); +} + +#[test] +fn remove_symlink_deletes_our_link() { + let tmp = tempfile::tempdir().unwrap(); + let exe = fake_exe(tmp.path()); + let bin = tmp.path().join("bin"); + let link = install_symlink(&exe, &bin, false).unwrap(); + + remove_symlink(&exe, &link).unwrap(); + + assert!(std::fs::symlink_metadata(&link).is_err()); +} + +#[test] +fn remove_symlink_refuses_plain_file() { + let tmp = tempfile::tempdir().unwrap(); + let exe = fake_exe(tmp.path()); + let bin = tmp.path().join("bin").tap_create(); + let entry = bin.join("tabularis"); + std::fs::write(&entry, b"the real binary").unwrap(); + + let err = remove_symlink(&exe, &entry).unwrap_err(); + + assert!(err.contains("not a symlink"), "unexpected error: {}", err); + assert!(entry.exists()); +} + +#[test] +fn remove_symlink_refuses_link_to_another_target() { + let tmp = tempfile::tempdir().unwrap(); + let exe = fake_exe(tmp.path()); + let other = fake_exe(&tmp.path().join("other").tap_create()); + let bin = tmp.path().join("bin"); + let link = install_symlink(&other, &bin, false).unwrap(); + + let err = remove_symlink(&exe, &link).unwrap_err(); + + assert!( + err.contains("does not point to this binary"), + "unexpected error: {}", + err + ); + assert!(std::fs::symlink_metadata(&link).is_ok()); +} + /// Tiny helper so the stale-symlink test can create a sibling dir inline. trait TapCreate { fn tap_create(self) -> PathBuf; diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index cec36e18..4b16513e 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -4374,3 +4374,23 @@ pub async fn import_connections_payload( Ok(()) } + +// --- Terminal CLI shortcut ---------------------------------------------------- + +/// Install state of the `tabularis` terminal command, for the Settings page. +#[tauri::command] +pub fn get_cli_install_status() -> crate::cli::install::CliInstallStatus { + crate::cli::install::install_status() +} + +/// Install the `tabularis` terminal command from the Settings page. +#[tauri::command] +pub fn install_cli_shortcut(force: bool) -> Result { + crate::cli::install::install_from_gui(force) +} + +/// Remove the `tabularis` terminal command from the Settings page. +#[tauri::command] +pub fn remove_cli_shortcut() -> Result { + crate::cli::install::uninstall_from_gui() +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f5be210b..35702f71 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -335,6 +335,10 @@ pub fn run() { commands::get_view_columns, commands::set_window_title, commands::open_er_diagram_window, + // Terminal CLI shortcut + commands::get_cli_install_status, + commands::install_cli_shortcut, + commands::remove_cli_shortcut, explain_import::load_explain_from_file, explain_import::get_pending_explain_file, explain_import::open_visual_explain_window, diff --git a/src/components/settings/CliInstallSection.tsx b/src/components/settings/CliInstallSection.tsx new file mode 100644 index 00000000..2c539457 --- /dev/null +++ b/src/components/settings/CliInstallSection.tsx @@ -0,0 +1,149 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { invoke } from "@tauri-apps/api/core"; +import { CheckCircle2, Loader2, Terminal, Trash2 } from "lucide-react"; +import { SettingRow, SettingSection } from "./SettingControls"; +import { + binDirFromLink, + isForceableInstallError, + pathExportLine, + type CliInstallStatus, +} from "../../utils/cli"; + +export function CliInstallSection() { + const { t } = useTranslation(); + const [status, setStatus] = useState(null); + const [isInstalling, setIsInstalling] = useState(false); + const [error, setError] = useState(null); + const [canForce, setCanForce] = useState(false); + + useEffect(() => { + let cancelled = false; + invoke("get_cli_install_status") + .then((s) => { + if (!cancelled) setStatus(s); + }) + .catch(() => { + if (!cancelled) setStatus(null); + }); + return () => { + cancelled = true; + }; + }, []); + + if (!status?.supported) { + return null; + } + + const install = async (force: boolean) => { + setIsInstalling(true); + setError(null); + try { + const next = await invoke("install_cli_shortcut", { + force, + }); + setStatus(next); + setCanForce(false); + } catch (e) { + const message = String(e); + setError(message); + setCanForce(!force && isForceableInstallError(message)); + } finally { + setIsInstalling(false); + } + }; + + const remove = async () => { + setIsInstalling(true); + setError(null); + setCanForce(false); + try { + const next = await invoke("remove_cli_shortcut"); + setStatus(next); + } catch (e) { + setError(String(e)); + } finally { + setIsInstalling(false); + } + }; + + const binDir = status.linkPath ? binDirFromLink(status.linkPath) : null; + + return ( + } + > + + {status.installed ? ( +
+
+ + {t("settings.cli.installed")} +
+ {status.removable && ( + + )} +
+ ) : ( + + )} +
+ + {status.installed && status.linkPath && ( +
+ {status.linkPath} +
+ )} + + {status.installed && !status.inPath && binDir && ( +
+
+ {t("settings.cli.notInPath", { dir: binDir })} +
+ {pathExportLine(binDir)} +
+ )} + + {error && ( +
+
{error}
+ {canForce && ( + + )} +
+ )} +
+ ); +} diff --git a/src/components/settings/GeneralTab.tsx b/src/components/settings/GeneralTab.tsx index 9f22e47a..f38ce9c7 100644 --- a/src/components/settings/GeneralTab.tsx +++ b/src/components/settings/GeneralTab.tsx @@ -8,6 +8,7 @@ import { SettingButtonGroup, SettingNumberInput, } from "./SettingControls"; +import { CliInstallSection } from "./CliInstallSection"; export function GeneralTab() { const { t } = useTranslation(); @@ -127,6 +128,8 @@ export function GeneralTab() { /> + + ); } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index a3dc6b53..295f745a 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -447,6 +447,16 @@ "erDiagram": "ER-Diagramm", "erDiagramDefaultLayout": "Standard-Layout", "erDiagramDefaultLayoutDesc": "Wähle die Standard-Layout-Richtung für ER-Diagramme", + "cli": { + "title": "Befehlszeile", + "install": "Befehl 'tabularis' installieren", + "installDesc": "Erstellt einen Symlink im PATH, damit CLI und interaktive SQL-Shell im Terminal verfügbar sind.", + "installButton": "Installieren", + "installed": "Installiert", + "replaceButton": "Vorhandenen Eintrag ersetzen", + "removeButton": "Entfernen", + "notInPath": "{{dir}} ist nicht im PATH. Füge es deinem Shell-Profil hinzu:" + }, "plugins": { "title": "Plugins", "overviewTitle": "Plugin-Center", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 711dc824..2d8435d2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -468,6 +468,16 @@ "erDiagram": "ER Diagram", "erDiagramDefaultLayout": "Default Layout", "erDiagramDefaultLayoutDesc": "Choose the default layout direction for ER diagrams", + "cli": { + "title": "Command Line", + "install": "Install 'tabularis' command", + "installDesc": "Create a symlink in your PATH so the CLI and the interactive SQL shell can be used from a terminal.", + "installButton": "Install", + "installed": "Installed", + "replaceButton": "Replace existing entry", + "removeButton": "Remove", + "notInPath": "{{dir}} is not in your PATH. Add it to your shell profile:" + }, "plugins": { "title": "Plugins", "overviewTitle": "Plugin Center", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 134d8f9b..85e534e7 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -452,6 +452,16 @@ "erDiagram": "Diagrama ER", "erDiagramDefaultLayout": "Diseño Predeterminado", "erDiagramDefaultLayoutDesc": "Elige la dirección de diseño predeterminada para los diagramas ER", + "cli": { + "title": "Línea de comandos", + "install": "Instalar el comando 'tabularis'", + "installDesc": "Crea un enlace simbólico en el PATH para usar la CLI y la shell SQL interactiva desde la terminal.", + "installButton": "Instalar", + "installed": "Instalado", + "replaceButton": "Reemplazar la entrada existente", + "removeButton": "Eliminar", + "notInPath": "{{dir}} no está en el PATH. Añádelo al perfil de tu shell:" + }, "plugins": { "title": "Plugins", "overviewTitle": "Centro de plugins", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index adb85137..e0a73996 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -447,6 +447,16 @@ "erDiagram": "Diagramme ER", "erDiagramDefaultLayout": "Disposition par défaut", "erDiagramDefaultLayoutDesc": "Choisissez la direction de disposition par défaut pour les diagrammes ER", + "cli": { + "title": "Ligne de commande", + "install": "Installer la commande 'tabularis'", + "installDesc": "Crée un lien symbolique dans le PATH pour utiliser la CLI et le shell SQL interactif depuis le terminal.", + "installButton": "Installer", + "installed": "Installé", + "replaceButton": "Remplacer l'entrée existante", + "removeButton": "Supprimer", + "notInPath": "{{dir}} n'est pas dans le PATH. Ajoutez-le au profil de votre shell :" + }, "plugins": { "title": "Plugins", "overviewTitle": "Centre de plugins", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index bb541193..2e7a5cc2 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -452,6 +452,16 @@ "erDiagram": "Diagramma ER", "erDiagramDefaultLayout": "Layout Predefinito", "erDiagramDefaultLayoutDesc": "Scegli la direzione di default per i diagrammi ER", + "cli": { + "title": "Riga di comando", + "install": "Installa il comando 'tabularis'", + "installDesc": "Crea un collegamento simbolico nel PATH per usare la CLI e la shell SQL interattiva dal terminale.", + "installButton": "Installa", + "installed": "Installato", + "replaceButton": "Sostituisci la voce esistente", + "removeButton": "Rimuovi", + "notInPath": "{{dir}} non è nel PATH. Aggiungilo al profilo della tua shell:" + }, "plugins": { "title": "Plugin", "overviewTitle": "Centro Plugin", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 8e27523f..2f820037 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -461,6 +461,16 @@ "erDiagram": "ER 図", "erDiagramDefaultLayout": "デフォルトレイアウト", "erDiagramDefaultLayoutDesc": "ER 図のデフォルトのレイアウト方向を選択します", + "cli": { + "title": "コマンドライン", + "install": "'tabularis' コマンドをインストール", + "installDesc": "PATH にシンボリックリンクを作成し、ターミナルから CLI と対話型 SQL シェルを使えるようにします。", + "installButton": "インストール", + "installed": "インストール済み", + "replaceButton": "既存のエントリを置き換える", + "removeButton": "削除", + "notInPath": "{{dir}} は PATH にありません。シェルのプロファイルに追加してください:" + }, "plugins": { "title": "プラグイン", "overviewTitle": "プラグインセンター", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 2a59f05a..65fc8f97 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -442,6 +442,16 @@ "erDiagram": "ER-диаграмма", "erDiagramDefaultLayout": "Расположение по умолчанию", "erDiagramDefaultLayoutDesc": "Направление по умолчанию для ER-диаграмм", + "cli": { + "title": "Командная строка", + "install": "Установить команду 'tabularis'", + "installDesc": "Создаёт символическую ссылку в PATH, чтобы использовать CLI и интерактивную SQL-оболочку из терминала.", + "installButton": "Установить", + "installed": "Установлено", + "replaceButton": "Заменить существующую запись", + "removeButton": "Удалить", + "notInPath": "{{dir}} отсутствует в PATH. Добавьте его в профиль оболочки:" + }, "plugins": { "title": "Плагины", "overviewTitle": "Центр плагинов", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index acd3f8f8..a12a1b30 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -416,6 +416,16 @@ "erDiagram": "ER 图", "erDiagramDefaultLayout": "默认布局", "erDiagramDefaultLayoutDesc": "选择 ER 图的默认布局方向", + "cli": { + "title": "命令行", + "install": "安装 'tabularis' 命令", + "installDesc": "在 PATH 中创建符号链接,以便在终端中使用 CLI 和交互式 SQL shell。", + "installButton": "安装", + "installed": "已安装", + "replaceButton": "替换现有条目", + "removeButton": "移除", + "notInPath": "{{dir}} 不在 PATH 中。请将其添加到 shell 配置文件:" + }, "plugins": { "title": "插件", "overviewTitle": "插件中心", diff --git a/src/utils/cli.ts b/src/utils/cli.ts new file mode 100644 index 00000000..5f1d127b --- /dev/null +++ b/src/utils/cli.ts @@ -0,0 +1,31 @@ +// Helpers for the terminal CLI install section in Settings. + +/// Mirror of the Rust `CliInstallStatus` returned by `get_cli_install_status` +/// and `install_cli_shortcut`. +export interface CliInstallStatus { + supported: boolean; + installed: boolean; + linkPath: string | null; + inPath: boolean; + /** True when the entry is a removable symlink (not a package-manager binary). */ + removable: boolean; +} + +/** Directory containing the installed link, e.g. "/usr/local/bin/tabularis" → "/usr/local/bin". */ +export function binDirFromLink(linkPath: string): string { + const idx = linkPath.lastIndexOf("/"); + return idx > 0 ? linkPath.slice(0, idx) : linkPath; +} + +/** Shell line the user can copy to make the install directory reachable. */ +export function pathExportLine(dir: string): string { + return `export PATH="${dir}:$PATH"`; +} + +/** + * Whether a failed install can be retried with `force`: the backend refuses + * to overwrite a foreign `tabularis` entry unless forced. + */ +export function isForceableInstallError(message: string): boolean { + return message.includes("already exists"); +} diff --git a/tests/utils/cli.test.ts b/tests/utils/cli.test.ts new file mode 100644 index 00000000..b6082f3f --- /dev/null +++ b/tests/utils/cli.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { + binDirFromLink, + pathExportLine, + isForceableInstallError, +} from "../../src/utils/cli"; + +describe("cli", () => { + describe("binDirFromLink", () => { + it("returns the parent directory of the link", () => { + expect(binDirFromLink("/usr/local/bin/tabularis")).toBe("/usr/local/bin"); + expect(binDirFromLink("/home/user/.local/bin/tabularis")).toBe( + "/home/user/.local/bin", + ); + }); + + it("returns the input when there is no parent directory", () => { + expect(binDirFromLink("tabularis")).toBe("tabularis"); + expect(binDirFromLink("/tabularis")).toBe("/tabularis"); + }); + }); + + describe("pathExportLine", () => { + it("builds an export line for the given directory", () => { + expect(pathExportLine("/home/user/.local/bin")).toBe( + 'export PATH="/home/user/.local/bin:$PATH"', + ); + }); + }); + + describe("isForceableInstallError", () => { + it("detects the foreign-entry error from the backend", () => { + expect( + isForceableInstallError( + "/usr/local/bin/tabularis already exists (use --force to replace it)", + ), + ).toBe(true); + }); + + it("is false for unrelated errors", () => { + expect(isForceableInstallError("permission denied")).toBe(false); + expect(isForceableInstallError("")).toBe(false); + }); + }); +}); From bec1468d2e770b4b033728482e5d5c0c7eecdb06 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Wed, 10 Jun 2026 16:30:06 +0200 Subject: [PATCH 4/4] feat(cli): add tab completion, pager, statement splitter --- src-tauri/src/cli/args_tests.rs | 61 +++++++- src-tauri/src/cli/complete.rs | 215 ++++++++++++++++++++++++++ src-tauri/src/cli/complete_tests.rs | 130 ++++++++++++++++ src-tauri/src/cli/mod.rs | 15 ++ src-tauri/src/cli/output.rs | 31 ++++ src-tauri/src/cli/output_tests.rs | 66 ++++++-- src-tauri/src/cli/pager.rs | 79 ++++++++++ src-tauri/src/cli/pager_tests.rs | 59 +++++++ src-tauri/src/cli/repl.rs | 170 ++++++++++++++++---- src-tauri/src/cli/run.rs | 69 +++++++-- src-tauri/src/cli/statements.rs | 176 +++++++++++++++++++++ src-tauri/src/cli/statements_tests.rs | 160 +++++++++++++++++++ 12 files changed, 1171 insertions(+), 60 deletions(-) create mode 100644 src-tauri/src/cli/complete.rs create mode 100644 src-tauri/src/cli/complete_tests.rs create mode 100644 src-tauri/src/cli/pager.rs create mode 100644 src-tauri/src/cli/pager_tests.rs create mode 100644 src-tauri/src/cli/statements.rs create mode 100644 src-tauri/src/cli/statements_tests.rs diff --git a/src-tauri/src/cli/args_tests.rs b/src-tauri/src/cli/args_tests.rs index 9169c251..e6a5b4d9 100644 --- a/src-tauri/src/cli/args_tests.rs +++ b/src-tauri/src/cli/args_tests.rs @@ -47,6 +47,7 @@ fn query_with_sql_parses_with_defaults() { Some(CliCommand::Query { connection, sql, + file, database, limit, format, @@ -54,6 +55,7 @@ fn query_with_sql_parses_with_defaults() { }) => { assert_eq!(connection, "conn-1"); assert_eq!(sql.as_deref(), Some("select 1")); + assert_eq!(file, None); assert_eq!(database, None); assert_eq!(limit, 100); assert_eq!(format, OutputFormat::Table); @@ -92,11 +94,54 @@ fn query_format_accepts_known_values_only() { other => panic!("expected Query, got {:?}", other), } - let err = Args::try_parse_from(["tabularis", "query", "c", "s", "--format", "xml"]) - .unwrap_err(); + let err = + Args::try_parse_from(["tabularis", "query", "c", "s", "--format", "xml"]).unwrap_err(); assert_eq!(err.kind(), ErrorKind::InvalidValue); } +#[test] +fn query_format_accepts_expanded() { + let args = Args::try_parse_from([ + "tabularis", + "query", + "c", + "select 1", + "--format", + "expanded", + ]) + .unwrap(); + match args.command { + Some(CliCommand::Query { format, .. }) => assert_eq!(format, OutputFormat::Expanded), + other => panic!("expected Query, got {:?}", other), + } +} + +#[test] +fn query_accepts_file_flag() { + let args = Args::try_parse_from(["tabularis", "query", "conn-1", "-f", "script.sql"]).unwrap(); + match args.command { + Some(CliCommand::Query { sql, file, .. }) => { + assert!(sql.is_none()); + assert_eq!(file.as_deref(), Some(std::path::Path::new("script.sql"))); + } + other => panic!("expected Query, got {:?}", other), + } +} + +#[test] +fn query_rejects_sql_argument_together_with_file() { + let err = Args::try_parse_from([ + "tabularis", + "query", + "conn-1", + "select 1", + "--file", + "a.sql", + ]) + .unwrap_err(); + assert_eq!(err.kind(), ErrorKind::ArgumentConflict); +} + #[test] fn query_alias_q_works() { let args = Args::try_parse_from(["tabularis", "q", "conn-1", "select 1"]).unwrap(); @@ -117,7 +162,14 @@ fn connections_alias_ls_works() { #[test] fn tables_parses_database_and_schema() { let args = Args::try_parse_from([ - "tabularis", "tables", "conn-1", "-d", "db2", "--schema", "public", "--json", + "tabularis", + "tables", + "conn-1", + "-d", + "db2", + "--schema", + "public", + "--json", ]) .unwrap(); match args.command { @@ -145,8 +197,7 @@ fn describe_requires_table_argument() { #[test] fn install_cli_parses_dir_and_force() { let args = - Args::try_parse_from(["tabularis", "install-cli", "--dir", "/tmp/bin", "--force"]) - .unwrap(); + Args::try_parse_from(["tabularis", "install-cli", "--dir", "/tmp/bin", "--force"]).unwrap(); match args.command { Some(CliCommand::InstallCli { dir, force }) => { assert_eq!(dir.as_deref(), Some(std::path::Path::new("/tmp/bin"))); diff --git a/src-tauri/src/cli/complete.rs b/src-tauri/src/cli/complete.rs new file mode 100644 index 00000000..b5245c0e --- /dev/null +++ b/src-tauri/src/cli/complete.rs @@ -0,0 +1,215 @@ +//! Tab completion for the interactive shell. +//! +//! Candidates come from three sources: the shell's meta commands, a static +//! SQL keyword list, and a catalog of table/column names. The catalog is +//! loaded by a background task ([`spawn_catalog_refresh`]) so the prompt +//! never waits on metadata queries — completion simply gets richer as +//! results land. + +use crate::drivers::driver_trait::DatabaseDriver; +use crate::models::ConnectionParams; +use rustyline::completion::{Completer, Pair}; +use rustyline::highlight::Highlighter; +use rustyline::hint::Hinter; +use rustyline::validate::Validator; +use rustyline::{Context, Helper}; +use std::collections::BTreeMap; +use std::sync::{Arc, RwLock}; + +/// Table and column names known for the current database/schema. +#[derive(Default)] +pub struct Catalog { + pub tables: Vec, + /// Table name -> its column names. + pub columns: BTreeMap>, +} + +/// How many tables get their columns prefetched. Keeps the background +/// refresh cheap on very large schemas; tables beyond the cap still complete +/// by name, just without their columns. +const COLUMN_PREFETCH_CAP: usize = 200; + +const SQL_KEYWORDS: &[&str] = &[ + "ALTER", "AND", "AS", "ASC", "BEGIN", "BETWEEN", "BY", "CASE", "COMMIT", "COUNT", "CREATE", + "CROSS", "DELETE", "DESC", "DISTINCT", "DROP", "ELSE", "END", "EXISTS", "EXPLAIN", "FROM", + "FULL", "GROUP", "HAVING", "IN", "INDEX", "INNER", "INSERT", "INTO", "IS", "JOIN", "LEFT", + "LIKE", "LIMIT", "NOT", "NULL", "OFFSET", "ON", "OR", "ORDER", "OUTER", "PRIMARY", "RIGHT", + "ROLLBACK", "SELECT", "SET", "TABLE", "THEN", "TRUNCATE", "UNION", "UPDATE", "VALUES", "VIEW", + "WHEN", "WHERE", +]; + +const META_COMMANDS: &[&str] = &[ + "\\q", "\\?", "\\use", "\\l", "\\dn", "\\dt", "\\d", "\\f", "\\limit", "\\schema", "\\x", + "\\i", "\\pager", +]; + +const FORMATS: &[&str] = &["table", "expanded", "json", "csv"]; + +/// Byte offset where the word being completed starts: scan back from `pos` +/// to the previous token boundary (whitespace, punctuation, `.` of a +/// qualified name). +pub fn completion_start(line: &str, pos: usize) -> usize { + line[..pos] + .char_indices() + .rev() + .find(|(_, c)| { + c.is_whitespace() + || matches!( + c, + '(' | ')' | ',' | ';' | '=' | '<' | '>' | '+' | '*' | '/' | '.' + ) + }) + .map(|(i, c)| i + c.len_utf8()) + .unwrap_or(0) +} + +/// Compute the candidates for `word`, the token under the cursor in `line`. +pub fn candidates(line: &str, word: &str, catalog: &Catalog) -> Vec { + if line.starts_with('\\') { + return meta_candidates(line, word, catalog); + } + if word.is_empty() { + // A bare Tab would dump every keyword/table; stay quiet instead. + return Vec::new(); + } + + let lower = word.to_lowercase(); + let mut out: Vec = SQL_KEYWORDS + .iter() + .filter(|kw| kw.to_lowercase().starts_with(&lower)) + .map(|kw| match_case(kw, word)) + .collect(); + out.extend( + catalog + .tables + .iter() + .filter(|t| t.to_lowercase().starts_with(&lower)) + .cloned(), + ); + let mut columns: Vec = catalog + .columns + .values() + .flatten() + .filter(|c| c.to_lowercase().starts_with(&lower)) + .cloned() + .collect(); + columns.sort(); + out.extend(columns); + out.dedup(); + out +} + +fn meta_candidates(line: &str, word: &str, catalog: &Catalog) -> Vec { + // Still typing the command itself. + if word.starts_with('\\') { + return META_COMMANDS + .iter() + .filter(|m| m.starts_with(word)) + .map(|m| m.to_string()) + .collect(); + } + // Typing an argument: complete by command. + let lower = word.to_lowercase(); + match line.split_whitespace().next().unwrap_or("") { + "\\d" => catalog + .tables + .iter() + .filter(|t| t.to_lowercase().starts_with(&lower)) + .cloned() + .collect(), + "\\f" => FORMATS + .iter() + .filter(|f| f.starts_with(&lower)) + .map(|f| f.to_string()) + .collect(), + "\\pager" => ["on", "off"] + .iter() + .filter(|v| v.starts_with(&lower)) + .map(|v| v.to_string()) + .collect(), + _ => Vec::new(), + } +} + +/// Render a keyword in the case the user is typing: an all-lowercase prefix +/// completes to lowercase, anything else to the canonical uppercase. +fn match_case(keyword: &str, word: &str) -> String { + if word.chars().all(|c| !c.is_uppercase()) { + keyword.to_lowercase() + } else { + keyword.to_string() + } +} + +/// rustyline helper wiring [`candidates`] into line editing. +pub struct ShellHelper { + pub catalog: Arc>, +} + +impl Completer for ShellHelper { + type Candidate = Pair; + + fn complete( + &self, + line: &str, + pos: usize, + _ctx: &Context<'_>, + ) -> rustyline::Result<(usize, Vec)> { + let start = completion_start(line, pos); + let word = &line[start..pos]; + let catalog = self.catalog.read().unwrap_or_else(|e| e.into_inner()); + let pairs = candidates(line, word, &catalog) + .into_iter() + .map(|c| Pair { + display: c.clone(), + replacement: c, + }) + .collect(); + Ok((start, pairs)) + } +} + +impl Hinter for ShellHelper { + type Hint = String; +} + +impl Highlighter for ShellHelper {} +impl Validator for ShellHelper {} +impl Helper for ShellHelper {} + +/// Reload the catalog in the background. The shell's readline blocks its own +/// thread, so this runs on the runtime's other workers and fills `catalog` +/// while the user types. Errors only degrade completion and are logged at +/// debug level. +pub fn spawn_catalog_refresh( + catalog: Arc>, + driver: Arc, + params: ConnectionParams, + schema: Option, +) { + tokio::spawn(async move { + let tables = match driver.get_tables(¶ms, schema.as_deref()).await { + Ok(tables) => tables, + Err(e) => { + log::debug!("Completion catalog: get_tables failed: {}", e); + return; + } + }; + let names: Vec = tables.into_iter().map(|t| t.name).collect(); + { + let mut cat = catalog.write().unwrap_or_else(|e| e.into_inner()); + cat.tables = names.clone(); + cat.columns.clear(); + } + for table in names.iter().take(COLUMN_PREFETCH_CAP) { + match driver.get_columns(¶ms, table, schema.as_deref()).await { + Ok(columns) => { + let cols: Vec = columns.into_iter().map(|c| c.name).collect(); + let mut cat = catalog.write().unwrap_or_else(|e| e.into_inner()); + cat.columns.insert(table.clone(), cols); + } + Err(e) => log::debug!("Completion catalog: columns for {} failed: {}", table, e), + } + } + }); +} diff --git a/src-tauri/src/cli/complete_tests.rs b/src-tauri/src/cli/complete_tests.rs new file mode 100644 index 00000000..2d2a7273 --- /dev/null +++ b/src-tauri/src/cli/complete_tests.rs @@ -0,0 +1,130 @@ +use super::complete::{candidates, completion_start, Catalog}; +use std::collections::BTreeMap; + +fn catalog() -> Catalog { + let mut columns = BTreeMap::new(); + columns.insert( + "users".to_string(), + vec!["id".to_string(), "email".to_string()], + ); + columns.insert( + "posts".to_string(), + vec!["id".to_string(), "title".to_string()], + ); + Catalog { + tables: vec!["users".to_string(), "posts".to_string()], + columns, + } +} + +// --- completion_start ----------------------------------------------------------- + +#[test] +fn completion_start_at_line_start_is_zero() { + assert_eq!(completion_start("sel", 3), 0); +} + +#[test] +fn completion_start_after_whitespace() { + assert_eq!(completion_start("SELECT na", 9), 7); +} + +#[test] +fn completion_start_after_punctuation() { + assert_eq!(completion_start("SELECT id,na", 12), 10); + assert_eq!(completion_start("WHERE (a=b", 10), 9); +} + +#[test] +fn completion_start_after_qualified_name_dot() { + assert_eq!(completion_start("SELECT users.em", 15), 13); +} + +#[test] +fn completion_start_handles_multibyte_characters() { + let line = "SELECT 'é' , na"; + assert_eq!(completion_start(line, line.len()), line.len() - 2); +} + +// --- SQL candidates ------------------------------------------------------------- + +#[test] +fn empty_word_yields_no_candidates() { + assert!(candidates("SELECT ", "", &catalog()).is_empty()); +} + +#[test] +fn lowercase_prefix_completes_keywords_in_lowercase() { + let result = candidates("sel", "sel", &catalog()); + assert!(result.contains(&"select".to_string())); +} + +#[test] +fn uppercase_prefix_completes_keywords_in_uppercase() { + let result = candidates("SEL", "SEL", &catalog()); + assert!(result.contains(&"SELECT".to_string())); +} + +#[test] +fn tables_complete_case_insensitively() { + let result = candidates("SELECT * FROM us", "us", &catalog()); + assert!(result.contains(&"users".to_string())); + + let result = candidates("SELECT * FROM US", "US", &catalog()); + assert!(result.contains(&"users".to_string())); +} + +#[test] +fn columns_from_every_table_complete() { + let result = candidates("SELECT ti", "ti", &catalog()); + assert!(result.contains(&"title".to_string())); + + let result = candidates("SELECT em", "em", &catalog()); + assert!(result.contains(&"email".to_string())); +} + +#[test] +fn duplicate_candidates_are_removed() { + // `id` exists in both tables but must appear once. + let result = candidates("SELECT id", "id", &catalog()); + assert_eq!(result.iter().filter(|c| *c == "id").count(), 1); +} + +#[test] +fn empty_catalog_still_completes_keywords() { + let result = candidates("fro", "fro", &Catalog::default()); + assert_eq!(result, vec!["from".to_string()]); +} + +// --- meta command candidates ------------------------------------------------------ + +#[test] +fn backslash_prefix_completes_meta_commands() { + let result = candidates("\\d", "\\d", &catalog()); + assert!(result.contains(&"\\d".to_string())); + assert!(result.contains(&"\\dt".to_string())); + assert!(result.contains(&"\\dn".to_string())); +} + +#[test] +fn describe_argument_completes_table_names() { + let result = candidates("\\d us", "us", &catalog()); + assert_eq!(result, vec!["users".to_string()]); +} + +#[test] +fn format_argument_completes_formats() { + let result = candidates("\\f ex", "ex", &catalog()); + assert_eq!(result, vec!["expanded".to_string()]); +} + +#[test] +fn pager_argument_completes_on_off() { + let result = candidates("\\pager o", "o", &catalog()); + assert_eq!(result, vec!["on".to_string(), "off".to_string()]); +} + +#[test] +fn unknown_meta_argument_yields_no_candidates() { + assert!(candidates("\\limit 5", "5", &catalog()).is_empty()); +} diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs index 85f4ed37..2a371fbf 100644 --- a/src-tauri/src/cli/mod.rs +++ b/src-tauri/src/cli/mod.rs @@ -9,19 +9,28 @@ use clap::{Parser, Subcommand, ValueEnum}; +pub mod complete; pub mod install; pub mod output; +pub mod pager; pub mod repl; pub mod run; +pub mod statements; #[cfg(test)] pub mod args_tests; +#[cfg(test)] +pub mod complete_tests; #[cfg(all(test, unix))] pub mod install_tests; #[cfg(test)] pub mod output_tests; #[cfg(test)] +pub mod pager_tests; +#[cfg(test)] pub mod run_tests; +#[cfg(test)] +pub mod statements_tests; pub use run::run_command; @@ -51,6 +60,8 @@ pub struct Args { pub enum OutputFormat { /// Aligned ASCII table (psql-like) Table, + /// One block per record with `column | value` lines (psql `\x` style) + Expanded, /// JSON array of row objects Json, /// RFC 4180 CSV with a header row @@ -128,6 +139,10 @@ pub enum CliCommand { /// SQL to execute. When omitted, opens an interactive shell /// (or reads the SQL from stdin when piped) sql: Option, + /// Read the SQL from a file instead; every `;`-terminated statement + /// in it runs in order, stopping at the first error + #[arg(long, short = 'f', value_name = "FILE", conflicts_with = "sql")] + file: Option, /// Database to target (multi-database connections default to the /// first one; changeable inside the shell with \use) #[arg(long, short = 'd')] diff --git a/src-tauri/src/cli/output.rs b/src-tauri/src/cli/output.rs index 4506a8a7..52ebb082 100644 --- a/src-tauri/src/cli/output.rs +++ b/src-tauri/src/cli/output.rs @@ -115,6 +115,37 @@ pub fn render_table(headers: &[String], rows: &[Vec]) -> String { out } +/// Render rows vertically, one block per record (psql `\x` style): +/// +/// ```text +/// -[ RECORD 1 ]- +/// id | 1 +/// name | Alice +/// ``` +/// +/// Wide result sets stay readable because every column gets its own line. +pub fn render_expanded(headers: &[String], rows: &[Vec]) -> String { + let headers: Vec = headers.iter().map(|h| sanitize_text(h)).collect(); + let name_width = headers.iter().map(|h| h.chars().count()).max().unwrap_or(0); + + let mut out = String::new(); + for (i, row) in rows.iter().enumerate() { + if i > 0 { + out.push('\n'); + } + out.push_str(&format!("-[ RECORD {} ]-", i + 1)); + for (j, header) in headers.iter().enumerate() { + let cell = sanitize_text(row.get(j).map(String::as_str).unwrap_or("")); + out.push('\n'); + out.push_str(header); + out.push_str(&" ".repeat(name_width - header.chars().count())); + out.push_str(" | "); + out.push_str(&cell); + } + } + out +} + /// Render rows as CSV with a header row. pub fn render_csv(headers: &[String], rows: &[Vec]) -> Result { let mut writer = csv::Writer::from_writer(Vec::new()); diff --git a/src-tauri/src/cli/output_tests.rs b/src-tauri/src/cli/output_tests.rs index 1757f387..7ae1e58d 100644 --- a/src-tauri/src/cli/output_tests.rs +++ b/src-tauri/src/cli/output_tests.rs @@ -1,5 +1,6 @@ use super::output::{ - format_value, render_csv, render_json, render_table, result_to_rows, sanitize_text, + format_value, render_csv, render_expanded, render_json, render_table, result_to_rows, + sanitize_text, }; use crate::models::QueryResult; use serde_json::json; @@ -11,10 +12,7 @@ fn strings(values: &[&str]) -> Vec { fn sample_result() -> QueryResult { QueryResult { columns: strings(&["id", "name"]), - rows: vec![ - vec![json!(1), json!("Alice")], - vec![json!(2), json!(null)], - ], + rows: vec![vec![json!(1), json!("Alice")], vec![json!(2), json!(null)]], affected_rows: 0, truncated: false, pagination: None, @@ -51,7 +49,10 @@ fn format_value_renders_nested_json_compactly() { #[test] fn result_to_rows_maps_every_cell() { let rows = result_to_rows(&sample_result()); - assert_eq!(rows, vec![strings(&["1", "Alice"]), strings(&["2", "NULL"])]); + assert_eq!( + rows, + vec![strings(&["1", "Alice"]), strings(&["2", "NULL"])] + ); } // --- sanitize_text ---------------------------------------------------------- @@ -133,15 +134,58 @@ fn render_table_pads_missing_cells() { assert!(table.contains("| 1 | |")); } +// --- render_expanded ---------------------------------------------------------- + +#[test] +fn render_expanded_prints_one_block_per_record() { + let text = render_expanded( + &strings(&["id", "name"]), + &[strings(&["1", "Alice"]), strings(&["2", "Bo"])], + ); + let expected = "\ +-[ RECORD 1 ]- +id | 1 +name | Alice +-[ RECORD 2 ]- +id | 2 +name | Bo"; + assert_eq!(text, expected); +} + +#[test] +fn render_expanded_aligns_column_names_to_the_widest() { + let text = render_expanded(&strings(&["a", "long_name"]), &[strings(&["1", "2"])]); + assert!(text.contains("a | 1")); + assert!(text.contains("long_name | 2")); +} + +#[test] +fn render_expanded_with_no_rows_is_empty() { + assert_eq!(render_expanded(&strings(&["id"]), &[]), ""); +} + +#[test] +fn render_expanded_pads_missing_cells() { + let text = render_expanded(&strings(&["a", "b"]), &[strings(&["1"])]); + assert!(text.contains("b | ")); +} + +#[test] +fn render_expanded_escapes_control_characters_in_names_and_cells() { + let text = render_expanded( + &strings(&["na\x1b[2Jme"]), + &[strings(&["\x1b]0;owned\x07"])], + ); + assert!(!text.contains('\x1b')); + assert!(text.contains("na\\u{1b}[2Jme")); + assert!(text.contains("\\u{1b}]0;owned\\u{7}")); +} + // --- render_csv ------------------------------------------------------------- #[test] fn render_csv_writes_header_and_rows() { - let csv = render_csv( - &strings(&["id", "name"]), - &[strings(&["1", "Alice"])], - ) - .unwrap(); + let csv = render_csv(&strings(&["id", "name"]), &[strings(&["1", "Alice"])]).unwrap(); assert_eq!(csv, "id,name\n1,Alice\n"); } diff --git a/src-tauri/src/cli/pager.rs b/src-tauri/src/cli/pager.rs new file mode 100644 index 00000000..01b85a29 --- /dev/null +++ b/src-tauri/src/cli/pager.rs @@ -0,0 +1,79 @@ +//! Piping long output through a pager, psql-style. +//! +//! Table-like query results go through `$TABULARIS_PAGER`, `$PAGER` or +//! `less -RSFX` when stdout is an interactive terminal and the output is long +//! enough to scroll away. Piped/redirected output is never paged, so +//! `tabularis query … > out.txt` stays byte-exact. + +use std::io::{IsTerminal, Write}; +use std::process::{Command, Stdio}; + +/// Outputs shorter than this never engage the pager. It matches the smallest +/// common terminal height; longer output is delegated to the pager, which +/// (with `less -F`) still exits immediately when the text fits the real +/// screen. +const PAGER_MIN_LINES: usize = 24; + +/// Decide whether `text` should go through the pager. +pub fn should_page(text: &str, enabled: bool, stdout_is_tty: bool) -> bool { + enabled && stdout_is_tty && text.lines().count() >= PAGER_MIN_LINES +} + +/// Resolve the pager command line from the given environment values: +/// `$TABULARIS_PAGER` wins over `$PAGER`, a set-but-blank value disables +/// paging, and the fallback is `less -RSFX` (`-F`: quit if one screen, +/// `-S`: chop long table rows instead of wrapping, `-X`: no screen clear). +pub fn pager_command_from( + tabularis_pager: Option<&str>, + pager: Option<&str>, +) -> Option> { + let value = match (tabularis_pager, pager) { + (Some(v), _) | (None, Some(v)) => v, + (None, None) => "less -RSFX", + }; + let parts: Vec = value.split_whitespace().map(String::from).collect(); + if parts.is_empty() { + None + } else { + Some(parts) + } +} + +fn pager_command() -> Option> { + let tabularis_pager = std::env::var("TABULARIS_PAGER").ok(); + let pager = std::env::var("PAGER").ok(); + pager_command_from(tabularis_pager.as_deref(), pager.as_deref()) +} + +/// Print `text` followed by a newline, going through the pager when the +/// output is long and stdout is an interactive terminal. Every pager problem +/// (disabled, unresolvable, spawn failure) falls back to plain printing. +pub fn print_paged(text: &str, enabled: bool) { + if !should_page(text, enabled, std::io::stdout().is_terminal()) { + println!("{}", text); + return; + } + let Some(command) = pager_command() else { + println!("{}", text); + return; + }; + + match Command::new(&command[0]) + .args(&command[1..]) + .stdin(Stdio::piped()) + .spawn() + { + Ok(mut child) => { + if let Some(mut stdin) = child.stdin.take() { + // Write errors (EPIPE) just mean the user quit the pager early. + let _ = stdin.write_all(text.as_bytes()); + let _ = stdin.write_all(b"\n"); + } + let _ = child.wait(); + } + Err(e) => { + log::debug!("Failed to spawn pager {:?}: {}", command, e); + println!("{}", text); + } + } +} diff --git a/src-tauri/src/cli/pager_tests.rs b/src-tauri/src/cli/pager_tests.rs new file mode 100644 index 00000000..a3874c80 --- /dev/null +++ b/src-tauri/src/cli/pager_tests.rs @@ -0,0 +1,59 @@ +use super::pager::{pager_command_from, should_page}; + +fn long_text() -> String { + vec!["line"; 50].join("\n") +} + +// --- should_page ---------------------------------------------------------------- + +#[test] +fn should_page_requires_a_tty() { + assert!(!should_page(&long_text(), true, false)); +} + +#[test] +fn should_page_respects_the_enabled_flag() { + assert!(!should_page(&long_text(), false, true)); +} + +#[test] +fn should_page_skips_short_output() { + assert!(!should_page("one\ntwo\nthree", true, true)); +} + +#[test] +fn should_page_pages_long_output_on_a_tty() { + assert!(should_page(&long_text(), true, true)); +} + +// --- pager_command_from ---------------------------------------------------------- + +#[test] +fn defaults_to_less_with_safe_flags() { + assert_eq!( + pager_command_from(None, None), + Some(vec!["less".to_string(), "-RSFX".to_string()]) + ); +} + +#[test] +fn pager_env_is_used_and_split_into_arguments() { + assert_eq!( + pager_command_from(None, Some("more -d")), + Some(vec!["more".to_string(), "-d".to_string()]) + ); +} + +#[test] +fn tabularis_pager_wins_over_pager() { + assert_eq!( + pager_command_from(Some("bat --paging=always"), Some("more")), + Some(vec!["bat".to_string(), "--paging=always".to_string()]) + ); +} + +#[test] +fn blank_value_disables_paging() { + assert_eq!(pager_command_from(Some(""), Some("more")), None); + assert_eq!(pager_command_from(None, Some(" ")), None); +} diff --git a/src-tauri/src/cli/repl.rs b/src-tauri/src/cli/repl.rs index e62e4399..e7e3d62a 100644 --- a/src-tauri/src/cli/repl.rs +++ b/src-tauri/src/cli/repl.rs @@ -1,15 +1,21 @@ -//! Interactive SQL shell (`tabularis shell `). +//! Interactive SQL shell (`tabularis query `). //! //! Reads SQL statements terminated by `;` (multi-line input supported) and //! psql-style backslash meta commands, executing them against the resolved -//! driver. Line editing and persistent history come from rustyline. +//! driver. Line editing, persistent history and tab completion come from +//! rustyline; completion candidates are filled in the background by +//! [`super::complete::spawn_catalog_refresh`]. +use super::complete::{spawn_catalog_refresh, Catalog, ShellHelper}; use super::run::{effective_limit, override_database, print_query_result}; -use super::{output, OutputFormat}; +use super::{output, statements, OutputFormat}; +use crate::drivers::driver_trait::DatabaseDriver; use crate::headless; -use crate::models::DatabaseSelection; +use crate::models::{ConnectionParams, DatabaseSelection}; use rustyline::error::ReadlineError; -use rustyline::DefaultEditor; +use rustyline::history::DefaultHistory; +use rustyline::Editor; +use std::sync::{Arc, RwLock}; use std::time::Instant; const HISTORY_FILE: &str = "cli_history.txt"; @@ -18,9 +24,14 @@ struct ShellState { format: OutputFormat, limit: u32, schema: Option, + /// Page long table-like results through `$PAGER`/`less` (toggle: \pager). + pager: bool, /// Include the current database in the prompt — on for multi-database /// connections and after a `\use` switch. show_db_in_prompt: bool, + /// Shared with the rustyline helper; refreshed in the background on + /// startup and after `\use`/`\schema` switches. + catalog: Arc>, } pub async fn run_shell( @@ -61,10 +72,23 @@ pub async fn run_shell( format, limit, schema, + pager: true, show_db_in_prompt: multi_db || switched, + catalog: Arc::new(RwLock::new(Catalog::default())), }; - let mut editor = DefaultEditor::new().map_err(|e| e.to_string())?; + let mut editor: Editor = + Editor::new().map_err(|e| e.to_string())?; + editor.set_helper(Some(ShellHelper { + catalog: state.catalog.clone(), + })); + spawn_catalog_refresh( + state.catalog.clone(), + driver.clone(), + params.clone(), + state.schema.clone(), + ); + let history_path = crate::paths::get_app_config_dir().join(HISTORY_FILE); let _ = editor.load_history(&history_path); @@ -92,7 +116,7 @@ pub async fn run_shell( || trimmed.eq_ignore_ascii_case("quit") { let _ = editor.add_history_entry(trimmed); - if handle_meta(trimmed, &mut state, &mut params, &*driver).await { + if handle_meta(trimmed, &mut state, &mut params, &driver).await { break; } continue; @@ -111,21 +135,7 @@ pub async fn run_shell( if sql.is_empty() { continue; } - - let start = Instant::now(); - match driver - .execute_query( - ¶ms, - sql, - effective_limit(state.limit), - 1, - state.schema.as_deref(), - ) - .await - { - Ok(result) => print_query_result(&result, state.format, start.elapsed()), - Err(e) => print_error(&e), - } + execute_statement(sql, &state, ¶ms, &driver).await; } } // Ctrl-C: drop any half-typed statement, keep the shell alive. @@ -145,12 +155,73 @@ pub async fn run_shell( Ok(()) } +/// Execute one SQL statement and print its result or error. +async fn execute_statement( + sql: &str, + state: &ShellState, + params: &ConnectionParams, + driver: &Arc, +) -> bool { + let start = Instant::now(); + match driver + .execute_query( + params, + sql, + effective_limit(state.limit), + 1, + state.schema.as_deref(), + ) + .await + { + Ok(result) => { + print_query_result(&result, state.format, start.elapsed(), state.pager); + true + } + Err(e) => { + print_error(&e); + false + } + } +} + +/// Run every statement of a SQL file (`\i `), stopping at the first +/// error but keeping the shell alive. +async fn run_script( + path: &str, + state: &ShellState, + params: &ConnectionParams, + driver: &Arc, +) { + let script = match std::fs::read_to_string(path) { + Ok(script) => script, + Err(e) => { + print_error(&format!("cannot read {}: {}", path, e)); + return; + } + }; + let statements = statements::split_sql_statements(&script); + if statements.is_empty() { + println!("No statements found in {}", path); + return; + } + for (index, statement) in statements.iter().enumerate() { + if !execute_statement(statement, state, params, driver).await { + eprintln!( + "(stopped at statement {} of {})", + index + 1, + statements.len() + ); + return; + } + } +} + /// Handle a meta command. Returns `true` when the shell should exit. async fn handle_meta( input: &str, state: &mut ShellState, - params: &mut crate::models::ConnectionParams, - driver: &dyn crate::drivers::driver_trait::DatabaseDriver, + params: &mut ConnectionParams, + driver: &Arc, ) -> bool { let mut parts = input.split_whitespace(); let command = parts.next().unwrap_or(""); @@ -169,6 +240,12 @@ async fn handle_meta( Ok(()) => { *params = candidate; state.show_db_in_prompt = true; + spawn_catalog_refresh( + state.catalog.clone(), + driver.clone(), + params.clone(), + state.schema.clone(), + ); println!("Now using database {}", db); } Err(e) => print_error(&format!("cannot switch to {}: {}", db, e)), @@ -195,12 +272,27 @@ async fn handle_meta( Some(table) => describe_table(state, params, driver, table).await, None => eprintln!("Usage: \\d
"), }, + // The path is everything after the command, so paths with spaces work. + "\\i" => match input["\\i".len()..].trim() { + "" => eprintln!("Usage: \\i "), + path => run_script(path, state, params, driver).await, + }, "\\f" => match arg { Some("table") => state.format = OutputFormat::Table, + Some("expanded") => state.format = OutputFormat::Expanded, Some("json") => state.format = OutputFormat::Json, Some("csv") => state.format = OutputFormat::Csv, - _ => eprintln!("Usage: \\f "), + _ => eprintln!("Usage: \\f "), }, + "\\x" => { + if state.format == OutputFormat::Expanded { + state.format = OutputFormat::Table; + println!("Expanded display off"); + } else { + state.format = OutputFormat::Expanded; + println!("Expanded display on"); + } + } "\\limit" => match arg.and_then(|a| a.parse::().ok()) { Some(n) => { state.limit = n; @@ -214,11 +306,29 @@ async fn handle_meta( }, "\\schema" => { state.schema = arg.map(String::from); + spawn_catalog_refresh( + state.catalog.clone(), + driver.clone(), + params.clone(), + state.schema.clone(), + ); match &state.schema { Some(s) => println!("Schema set to {}", s), None => println!("Schema reset to driver default"), } } + "\\pager" => match arg { + Some("on") => { + state.pager = true; + println!("Pager enabled"); + } + Some("off") => { + state.pager = false; + println!("Pager disabled"); + } + None => println!("Pager is {}", if state.pager { "on" } else { "off" }), + _ => eprintln!("Usage: \\pager "), + }, _ => eprintln!("Unknown command: {} (\\? for help)", command), } false @@ -226,8 +336,8 @@ async fn handle_meta( async fn describe_table( state: &ShellState, - params: &crate::models::ConnectionParams, - driver: &dyn crate::drivers::driver_trait::DatabaseDriver, + params: &ConnectionParams, + driver: &Arc, table: &str, ) { let schema = state.schema.as_deref(); @@ -277,10 +387,14 @@ fn print_help() { \\dn List schemas \\dt List tables (in the current schema) \\d
Show the columns of a table - \\f Set the output format + \\i Run the SQL statements from a file + \\f Set the output format + \\x Toggle expanded (vertical) output \\limit Set the row limit (0 = unlimited) \\schema [name] Set or reset the current schema + \\pager Enable or disable the pager for long results +Tab completes SQL keywords, table/column names and meta commands. Any other input is buffered as SQL and executed when a line ends with ';'. Each statement runs on its own pooled connection, so session state (SET, BEGIN/COMMIT, temp tables) does not persist between statements." diff --git a/src-tauri/src/cli/run.rs b/src-tauri/src/cli/run.rs index 5de27cd8..b34c7753 100644 --- a/src-tauri/src/cli/run.rs +++ b/src-tauri/src/cli/run.rs @@ -58,11 +58,23 @@ async fn execute(command: CliCommand) -> Result<(), String> { CliCommand::Query { connection, sql, + file, database, limit, format, schema, - } => cmd_query(&connection, sql, database, limit, format, schema.as_deref()).await, + } => { + cmd_query( + &connection, + sql, + file, + database, + limit, + format, + schema.as_deref(), + ) + .await + } CliCommand::InstallCli { dir, force } => super::install::run_install(dir, force), } } @@ -255,16 +267,19 @@ async fn cmd_describe( async fn cmd_query( connection: &str, sql: Option, + file: Option, database: Option, limit: u32, format: OutputFormat, schema: Option<&str>, ) -> Result<(), String> { - let sql = match sql { - Some(s) => s, + let script = match (sql, file) { + (Some(s), _) => s, + (None, Some(path)) => std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?, // No SQL argument: with an interactive terminal this becomes the // shell; with piped input the SQL is read from stdin instead. - None if std::io::stdin().is_terminal() => { + (None, None) if std::io::stdin().is_terminal() => { return super::repl::run_shell( connection, database, @@ -274,7 +289,7 @@ async fn cmd_query( ) .await; } - None => { + (None, None) => { let mut buf = String::new(); std::io::stdin() .read_to_string(&mut buf) @@ -282,18 +297,31 @@ async fn cmd_query( buf } }; - let sql = sql.trim(); - if sql.is_empty() { - return Err("No SQL provided (pass it as an argument or pipe it via stdin)".to_string()); + let statements = super::statements::split_sql_statements(&script); + if statements.is_empty() { + return Err( + "No SQL provided (pass it as an argument, via --file, or pipe it via stdin)" + .to_string(), + ); } let (_, mut params, driver) = headless::resolve_db_driver(connection).await?; override_database(&mut params, database); - let start = Instant::now(); - let result = driver - .execute_query(¶ms, sql, effective_limit(limit), 1, schema) - .await?; - print_query_result(&result, format, start.elapsed()); + let multiple = statements.len() > 1; + for (index, statement) in statements.iter().enumerate() { + let start = Instant::now(); + let result = driver + .execute_query(¶ms, statement, effective_limit(limit), 1, schema) + .await + .map_err(|e| { + if multiple { + format!("Statement {} failed: {}", index + 1, e) + } else { + e + } + })?; + print_query_result(&result, format, start.elapsed(), true); + } Ok(()) } @@ -308,11 +336,13 @@ pub(crate) fn effective_limit(limit: u32) -> Option { /// Print a query result in the requested format. Statements that return no /// result set (INSERT/UPDATE/DDL) print an `OK` line with the affected-row -/// count instead. +/// count instead. Table-like formats go through the pager when `pager` is set +/// and stdout is an interactive terminal. pub(crate) fn print_query_result( result: &QueryResult, format: OutputFormat, elapsed: std::time::Duration, + pager: bool, ) { let ms = elapsed.as_millis(); @@ -321,11 +351,18 @@ pub(crate) fn print_query_result( return; } + let truncated = if result.truncated { ", truncated" } else { "" }; match format { OutputFormat::Table => { let rows = output::result_to_rows(result); - println!("{}", output::render_table(&result.columns, &rows)); - let truncated = if result.truncated { ", truncated" } else { "" }; + super::pager::print_paged(&output::render_table(&result.columns, &rows), pager); + println!("({} rows{} in {} ms)", rows.len(), truncated, ms); + } + OutputFormat::Expanded => { + let rows = output::result_to_rows(result); + if !rows.is_empty() { + super::pager::print_paged(&output::render_expanded(&result.columns, &rows), pager); + } println!("({} rows{} in {} ms)", rows.len(), truncated, ms); } OutputFormat::Json => println!("{}", output::render_json(result)), diff --git a/src-tauri/src/cli/statements.rs b/src-tauri/src/cli/statements.rs new file mode 100644 index 00000000..30b43dac --- /dev/null +++ b/src-tauri/src/cli/statements.rs @@ -0,0 +1,176 @@ +//! Splitting a SQL script into individual statements. +//! +//! The splitter understands just enough SQL lexing to never cut a statement +//! inside a string (`'…'`, `"…"`, `` `…` ``), a comment (`-- …`, `/* … */` +//! with nesting) or a Postgres dollar-quoted body (`$tag$ … $tag$`); a `;` +//! anywhere else ends the current statement. Everything is copied through +//! verbatim, so the returned statements are exactly what was written, minus +//! the terminating semicolons. Backslashes inside quotes are treated as +//! escapes (MySQL-style); strict-SQL scripts relying on a literal `\` right +//! before a closing quote should use doubled quotes instead. + +/// Split `sql` into trimmed, non-empty statements. Statement-less fragments +/// (whitespace or comments only) are dropped, and a final statement without a +/// trailing `;` is included. +pub fn split_sql_statements(sql: &str) -> Vec { + let chars: Vec = sql.chars().collect(); + let mut statements = Vec::new(); + let mut current = String::new(); + let mut i = 0; + + while i < chars.len() { + let c = chars[i]; + match c { + ';' => { + push_statement(&mut statements, &mut current); + i += 1; + } + '\'' | '"' | '`' => i = copy_quoted(&chars, i, &mut current), + '-' if chars.get(i + 1) == Some(&'-') => i = copy_line_comment(&chars, i, &mut current), + '/' if chars.get(i + 1) == Some(&'*') => { + i = copy_block_comment(&chars, i, &mut current) + } + '$' => i = copy_dollar_quoted(&chars, i, &mut current), + _ => { + current.push(c); + i += 1; + } + } + } + push_statement(&mut statements, &mut current); + statements +} + +fn push_statement(statements: &mut Vec, current: &mut String) { + let statement = current.trim(); + if !statement.is_empty() && !is_comment_or_blank(statement) { + statements.push(statement.to_string()); + } + current.clear(); +} + +/// True when `statement` contains only whitespace and comments, i.e. nothing +/// a database server could execute. +fn is_comment_or_blank(statement: &str) -> bool { + let chars: Vec = statement.chars().collect(); + let mut i = 0; + let mut sink = String::new(); + while i < chars.len() { + let c = chars[i]; + if c.is_whitespace() { + i += 1; + } else if c == '-' && chars.get(i + 1) == Some(&'-') { + i = copy_line_comment(&chars, i, &mut sink); + } else if c == '/' && chars.get(i + 1) == Some(&'*') { + i = copy_block_comment(&chars, i, &mut sink); + } else { + return false; + } + } + true +} + +/// Copy a quoted span starting at `start` (which holds the quote character), +/// honouring doubled quotes (`''`, `""`, ``` `` ```) and, for `'`/`"`, +/// backslash escapes. Returns the index after the closing quote; an +/// unterminated quote consumes the rest of the input. +fn copy_quoted(chars: &[char], start: usize, out: &mut String) -> usize { + let quote = chars[start]; + out.push(quote); + let mut i = start + 1; + while i < chars.len() { + let c = chars[i]; + if c == '\\' && quote != '`' { + out.push(c); + i += 1; + if i < chars.len() { + out.push(chars[i]); + i += 1; + } + continue; + } + if c == quote { + if chars.get(i + 1) == Some("e) { + out.push(c); + out.push(c); + i += 2; + continue; + } + out.push(c); + return i + 1; + } + out.push(c); + i += 1; + } + i +} + +/// Copy a `--` comment up to and including the newline that ends it. +fn copy_line_comment(chars: &[char], start: usize, out: &mut String) -> usize { + let mut i = start; + while i < chars.len() { + out.push(chars[i]); + if chars[i] == '\n' { + return i + 1; + } + i += 1; + } + i +} + +/// Copy a `/* … */` comment, honouring nesting (Postgres nests them). +fn copy_block_comment(chars: &[char], start: usize, out: &mut String) -> usize { + out.push('/'); + out.push('*'); + let mut i = start + 2; + let mut depth = 1usize; + while i < chars.len() && depth > 0 { + if chars[i] == '/' && chars.get(i + 1) == Some(&'*') { + depth += 1; + out.push('/'); + out.push('*'); + i += 2; + } else if chars[i] == '*' && chars.get(i + 1) == Some(&'/') { + depth -= 1; + out.push('*'); + out.push('/'); + i += 2; + } else { + out.push(chars[i]); + i += 1; + } + } + i +} + +/// Copy a Postgres dollar-quoted span (`$$ … $$` or `$tag$ … $tag$`). When the +/// `$` at `start` does not open a valid delimiter (e.g. a `$1` placeholder), +/// it is copied as a plain character instead. +fn copy_dollar_quoted(chars: &[char], start: usize, out: &mut String) -> usize { + // A delimiter is `$` + optional tag + `$`, where the tag must not start + // with a digit (that is how `$1$` placeholders-ish text stays untouched). + let mut j = start + 1; + while j < chars.len() && (chars[j].is_ascii_alphanumeric() || chars[j] == '_') { + j += 1; + } + let tag_starts_with_digit = chars + .get(start + 1) + .is_some_and(|c| c.is_ascii_digit() && j > start + 1); + if chars.get(j) != Some(&'$') || tag_starts_with_digit { + out.push('$'); + return start + 1; + } + + let delimiter: Vec = chars[start..=j].to_vec(); + out.extend(delimiter.iter()); + let mut i = j + 1; + while i < chars.len() { + if chars[i] == '$' && chars[i..].starts_with(&delimiter[..]) { + out.extend(delimiter.iter()); + return i + delimiter.len(); + } + out.push(chars[i]); + i += 1; + } + i +} diff --git a/src-tauri/src/cli/statements_tests.rs b/src-tauri/src/cli/statements_tests.rs new file mode 100644 index 00000000..a727d6de --- /dev/null +++ b/src-tauri/src/cli/statements_tests.rs @@ -0,0 +1,160 @@ +use super::statements::split_sql_statements; + +fn split(sql: &str) -> Vec { + split_sql_statements(sql) +} + +// --- basic splitting ---------------------------------------------------------- + +#[test] +fn splits_on_semicolons_and_trims() { + assert_eq!( + split("SELECT 1; SELECT 2 ;\nSELECT 3"), + vec!["SELECT 1", "SELECT 2", "SELECT 3"] + ); +} + +#[test] +fn single_statement_without_trailing_semicolon_is_kept() { + assert_eq!(split("SELECT 1"), vec!["SELECT 1"]); +} + +#[test] +fn empty_and_whitespace_fragments_are_dropped() { + assert_eq!(split(";; ;\n;SELECT 1;;"), vec!["SELECT 1"]); + assert!(split("").is_empty()); + assert!(split(" \n\t ").is_empty()); +} + +#[test] +fn multiline_statements_keep_their_internal_newlines() { + assert_eq!( + split("SELECT a\nFROM t;\nSELECT 2;"), + vec!["SELECT a\nFROM t", "SELECT 2"] + ); +} + +// --- quoting ------------------------------------------------------------------ + +#[test] +fn semicolon_inside_single_quotes_does_not_split() { + assert_eq!( + split("INSERT INTO t VALUES ('a;b'); SELECT 1;"), + vec!["INSERT INTO t VALUES ('a;b')", "SELECT 1"] + ); +} + +#[test] +fn semicolon_inside_double_quotes_and_backticks_does_not_split() { + assert_eq!( + split(r#"SELECT "col;umn" FROM t; SELECT `we;ird` FROM u;"#), + vec![r#"SELECT "col;umn" FROM t"#, "SELECT `we;ird` FROM u"] + ); +} + +#[test] +fn doubled_quote_escape_stays_inside_the_string() { + assert_eq!( + split("SELECT 'it''s; fine'; SELECT 2;"), + vec!["SELECT 'it''s; fine'", "SELECT 2"] + ); +} + +#[test] +fn backslash_escaped_quote_stays_inside_the_string() { + assert_eq!( + split(r"SELECT 'it\'s; fine'; SELECT 2;"), + vec![r"SELECT 'it\'s; fine'", "SELECT 2"] + ); +} + +#[test] +fn unterminated_quote_consumes_the_rest() { + assert_eq!( + split("SELECT 'open; SELECT 2;"), + vec!["SELECT 'open; SELECT 2;"] + ); +} + +// --- comments ----------------------------------------------------------------- + +#[test] +fn semicolon_in_line_comment_does_not_split() { + assert_eq!( + split("SELECT 1 -- not; here\n; SELECT 2;"), + vec!["SELECT 1 -- not; here", "SELECT 2"] + ); +} + +#[test] +fn semicolon_in_block_comment_does_not_split() { + assert_eq!( + split("SELECT /* a;b */ 1; SELECT 2;"), + vec!["SELECT /* a;b */ 1", "SELECT 2"] + ); +} + +#[test] +fn nested_block_comments_are_handled() { + assert_eq!( + split("SELECT /* outer /* inner; */ still; */ 1; SELECT 2;"), + vec!["SELECT /* outer /* inner; */ still; */ 1", "SELECT 2"] + ); +} + +#[test] +fn comment_only_fragments_are_dropped() { + // A leading comment stays attached to its statement (servers accept + // comments); what gets dropped is a fragment with *only* comments in it. + assert_eq!( + split("-- header comment\nSELECT 1;\n-- trailing comment\n/* done */"), + vec!["-- header comment\nSELECT 1"] + ); +} + +#[test] +fn double_dash_inside_string_is_not_a_comment() { + assert_eq!( + split("SELECT '--not a comment;'; SELECT 2;"), + vec!["SELECT '--not a comment;'", "SELECT 2"] + ); +} + +// --- dollar quoting ----------------------------------------------------------- + +#[test] +fn semicolons_inside_dollar_quoted_bodies_do_not_split() { + let body = "CREATE FUNCTION f() RETURNS void AS $$\nBEGIN\n SELECT 1;\n SELECT 2;\nEND;\n$$ LANGUAGE plpgsql"; + let script = format!("{};\nSELECT 3;", body); + assert_eq!( + split(&script), + vec![body.to_string(), "SELECT 3".to_string()] + ); +} + +#[test] +fn tagged_dollar_quotes_must_match_the_same_tag() { + let body = "DO $tag$ inner; $other$ still inside; $tag$"; + let script = format!("{}; SELECT 1;", body); + assert_eq!( + split(&script), + vec![body.to_string(), "SELECT 1".to_string()] + ); +} + +#[test] +fn dollar_placeholders_are_not_dollar_quotes() { + assert_eq!( + split("SELECT * FROM t WHERE id = $1; SELECT 2;"), + vec!["SELECT * FROM t WHERE id = $1", "SELECT 2"] + ); +} + +#[test] +fn dollar_tag_starting_with_digit_is_not_a_delimiter() { + // `$1$` is not a valid Postgres dollar-quote tag. + assert_eq!( + split("SELECT '$' || $1; SELECT 2;"), + vec!["SELECT '$' || $1", "SELECT 2"] + ); +}