Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
557 changes: 548 additions & 9 deletions rust/cube/Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions rust/cube/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ members = [
"cubeorchestrator",
"cubesqlplanner/cubesqlplanner",
"cubesqlplanner/nativebridge",
"cubestore-ws-transport",
"cubestore-cli",
]
24 changes: 24 additions & 0 deletions rust/cube/cubestore-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[package]
name = "cubestore-cli"
version = "0.1.0"
edition = "2021"
description = "Interactive CLI for CubeStore over its native WebSocket protocol."

[lib]
path = "src/lib.rs"

[[bin]]
name = "csql"
path = "src/bin/csql.rs"

[dependencies]
cubestore-ws-transport = { path = "../cubestore-ws-transport" }
clap = { version = "4", features = ["derive", "env"] }
tokio = { version = "1", features = ["full"] }
rustyline = "14"
anyhow = "1"
url = "2"
rpassword = "7"
dirs = "5"
env_logger = "0.11"
log = "0.4"
32 changes: 32 additions & 0 deletions rust/cube/cubestore-cli/src/args.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
use std::path::PathBuf;

use clap::Parser;

#[derive(Debug, Parser)]
#[command(name = "cubestore-cli", version, about, long_about = None)]
pub struct Cli {
/// Full WebSocket URL (ws://host:port or wss://host:port). Takes precedence over --host/--port.
#[arg(long, env = "CUBESTORE_URL")]
pub url: Option<String>,

#[arg(long, env = "CUBESTORE_HOST", default_value = "127.0.0.1")]
pub host: String,

#[arg(long, env = "CUBESTORE_PORT", default_value_t = 3030)]
pub port: u16,

#[arg(long, env = "CUBESTORE_USER")]
pub user: Option<String>,

/// Pass `-` to read from a TTY prompt.
#[arg(long, env = "CUBESTORE_PASSWORD")]
pub password: Option<String>,

/// Execute a single SQL statement and exit.
#[arg(short = 'c', long = "command")]
pub command: Option<String>,

/// Execute SQL statements from a file and exit.
#[arg(short = 'f', long = "file")]
pub file: Option<PathBuf>,
}
86 changes: 86 additions & 0 deletions rust/cube/cubestore-cli/src/bin/csql.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
use std::io::{IsTerminal, Read};

use anyhow::{Context, Result};
use clap::Parser;
use cubestore_cli::args::Cli;
use cubestore_cli::{exec, repl};
use cubestore_ws_transport::{Client, ClientConfig};

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
let cli = Cli::parse();

let url = build_url(&cli)?;
let mut cfg = ClientConfig::new(url);
// CLI flags override credentials embedded in the URL, but only when they
// are actually provided — otherwise we'd clobber the URL-side creds with
// None.
if cli.user.is_some() {
cfg.username = cli.user.clone();
}
if let Some(pass) = resolve_password(&cli)? {
cfg.password = Some(pass);
}

let client = Client::connect(cfg)
.await
.context("failed to connect to cubestore")?;

// Dispatch:
// -c <SQL> → run single statement, exit
// -f <path> → run file as script, exit
// stdin not a TTY → read all stdin as script, exit
// otherwise → REPL
let result = if let Some(sql) = cli.command.as_deref() {
let ok = exec::run_script(&client, sql, true).await?;
Ok::<bool, anyhow::Error>(ok)
} else if let Some(path) = cli.file.as_deref() {
let script = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let ok = exec::run_script(&client, &script, true).await?;
Ok(ok)
} else if !std::io::stdin().is_terminal() {
let mut script = String::new();
std::io::stdin().read_to_string(&mut script)?;
let ok = exec::run_script(&client, &script, true).await?;
Ok(ok)
} else {
print_banner(&client);
repl::run(&client, true).await?;
Ok(true)
};

client.close();
if !result? {
std::process::exit(1);
}

Ok(())
}

fn build_url(cli: &Cli) -> Result<url::Url> {
if let Some(raw) = cli.url.as_deref() {
return url::Url::parse(raw).with_context(|| format!("invalid --url: {raw}"));
}

let raw = format!("ws://{}:{}", cli.host, cli.port);
url::Url::parse(&raw).with_context(|| format!("invalid host/port: {raw}"))
}

fn resolve_password(cli: &Cli) -> Result<Option<String>> {
match cli.password.as_deref() {
Some("-") => {
let pw = rpassword::prompt_password("Password: ")?;
Ok(Some(pw))
}
Some(p) => Ok(Some(p.to_string())),
None => Ok(None),
}
}

fn print_banner(client: &Client) {
let ver = client.server_version().unwrap_or("unknown");
println!("csql {} (server: {})", env!("CARGO_PKG_VERSION"), ver);
println!("Type \"\\h\" for help, \"\\q\" to quit.");
}
117 changes: 117 additions & 0 deletions rust/cube/cubestore-cli/src/exec.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
use std::time::Instant;

use anyhow::Result;
use cubestore_ws_transport::Client;

use crate::format;

/// Run a single statement and print the result. Returns Ok even on a query error,
/// matching `psql` behavior in script mode (errors print but don't kill the process)
/// — caller decides whether to bail.
pub async fn run_one(client: &Client, sql: &str, show_timing: bool) -> Result<bool> {
let started = Instant::now();
match client.query(sql).await {
Ok(result) => {
let elapsed_ms = started.elapsed().as_millis();
let footer = make_footer(&result, show_timing, elapsed_ms);
// `\x1b[?7h` = DECAWM on. rustyline may turn autowrap off for its own
// cursor accounting and not restore it; without this, long table rows
// get clipped at the right edge of the terminal instead of wrapping.
print!("\x1b[?7h");
match format::render_table(&result) {
Some(table) => println!("{table}\n{footer}"),
None => println!("{footer}"),
}
Ok(true)
}
Err(e) => {
print!("\x1b[?7h");
eprintln!("ERROR: {e}");
Ok(false)
}
}
}

fn make_footer(
result: &cubestore_ws_transport::QueryResult,
show_timing: bool,
elapsed_ms: u128,
) -> String {
let has_table = !result.columns.is_empty();
let n = result.rows.len();
let rows_part = if has_table {
format!("{n} {}", if n == 1 { "row" } else { "rows" })
} else {
"OK".to_string()
};
if show_timing {
format!("({rows_part}, Time: {elapsed_ms} ms)")
} else {
format!("({rows_part})")
}
}

/// Split a script into SQL statements on `;` boundaries that are outside string
/// literals. Empty statements are skipped. Same level of rigor as `psql -f`.
pub fn split_statements(input: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut buf = String::new();
let mut in_single = false;
let mut in_double = false;
let mut chars = input.chars().peekable();

while let Some(c) = chars.next() {
match c {
// Inside a single-quoted string, '' is an escaped quote — stay inside.
'\'' if in_single => {
buf.push(c);
if chars.peek() == Some(&'\'') {
buf.push(chars.next().unwrap());
} else {
in_single = false;
}
}
'\'' if !in_double => {
buf.push(c);
in_single = true;
}
// Same rule for double-quoted identifiers: "" is an escaped ".
'"' if in_double => {
buf.push(c);
if chars.peek() == Some(&'"') {
buf.push(chars.next().unwrap());
} else {
in_double = false;
}
}
'"' if !in_single => {
buf.push(c);
in_double = true;
}
';' if !in_single && !in_double => {
let trimmed = buf.trim().to_string();
if !trimmed.is_empty() {
out.push(trimmed);
}
buf.clear();
}
_ => buf.push(c),
}
}

let trailing = buf.trim();
if !trailing.is_empty() {
out.push(trailing.to_string());
}
out
}

pub async fn run_script(client: &Client, script: &str, show_timing: bool) -> Result<bool> {
let stmts = split_statements(script);
let mut all_ok = true;
for stmt in stmts {
let ok = run_one(client, &stmt, show_timing).await?;
all_ok &= ok;
}
Ok(all_ok)
}
Loading
Loading