mod auth_cmd;
mod commands;
mod completion;
mod data_cmd;
mod session;
mod session_cmd;
use anyhow::Result;
use clap::{CommandFactory, Parser};
use clap_complete::env::Shells;
use std::{
fmt,
io::{self, Write},
};
use self::commands::Command;
use crate::auth;
use crate::config;
use crate::formatting::style;
use crate::qpapi::GrpcError;
const COMPLETE_ENV: &str = "QUERYPIE_COMPLETE";
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
enum ColorChoice {
Auto,
Always,
Never,
}
impl ColorChoice {
fn install(self) {
let choice = match self {
Self::Auto => anstream::ColorChoice::Auto,
Self::Always => anstream::ColorChoice::Always,
Self::Never => anstream::ColorChoice::Never,
};
choice.write_global();
}
}
#[derive(Debug, Parser)]
#[command(name = "querypie")]
#[command(about = "Query QueryPie databases from the terminal")]
#[command(display_name = "querypie-cli")]
#[command(version)]
#[command(
long_about = "QueryPie CLI authenticates with a lightweight webview session and runs catalog and SQL commands through QueryPie.",
after_help = "EXAMPLES:
querypie --host HOST auth login
querypie connection list
querypie query -c CONNECTION 'select 1;'"
)]
struct Cli {
#[arg(
long,
global = true,
value_enum,
default_value_t = ColorChoice::Auto,
value_name = "WHEN",
help = "When to use color: auto, always, never",
display_order = 0
)]
color: ColorChoice,
#[arg(
long,
global = true,
value_name = "PATH",
help = "Path to a QueryPie CLI config file",
display_order = 1
)]
config: Option<String>,
#[arg(
long,
global = true,
value_name = "HOST",
help = "QueryPie host",
add = clap_complete::ArgValueCompleter::new(completion::complete_hosts),
display_order = 6
)]
host: Option<String>,
#[arg(
short,
long,
global = true,
help = "Print verbose diagnostics",
display_order = 11
)]
verbose: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Clone)]
pub(super) struct Global {
host: String,
connection: String,
engine: String,
database: String,
schema: String,
verbose: bool,
}
pub fn run() -> Result<()> {
if complete_from_env()? {
return Ok(());
}
let cli = Cli::parse();
cli.color.install();
if let Command::Completion { shell } = &cli.command {
return print_completion(*shell);
}
let (global, command) = cli.into_global_and_command()?;
command.run(&global)
}
fn complete_from_env() -> Result<bool> {
let current_dir = std::env::current_dir().ok();
Ok(
clap_complete::CompleteEnv::with_factory(Cli::public_command_for_completion)
.var(COMPLETE_ENV)
.try_complete(std::env::args_os(), current_dir.as_deref())?,
)
}
fn print_completion(shell: clap_complete::Shell) -> Result<()> {
let mut completions = Vec::new();
let completer = std::env::current_exe()?.to_string_lossy().into_owned();
let shells = Shells::builtins();
let shell = shells
.completer(&shell.to_string())
.expect("clap shell values should match dynamic completers");
shell.write_registration(
COMPLETE_ENV,
"querypie",
"querypie",
&completer,
&mut completions,
)?;
if let Err(err) = io::stdout().write_all(&completions) {
if err.kind() != io::ErrorKind::BrokenPipe {
return Err(err.into());
}
}
Ok(())
}
impl Cli {
fn public_command_for_completion() -> clap::Command {
Self::command().mut_subcommand("auth", |_| {
clap::Command::new("auth")
.about("Log in, log out, and inspect authentication")
.subcommand(
clap::Command::new("login").about("Open a webview and log in to QueryPie"),
)
.subcommand(
clap::Command::new("logout")
.about("Log out and remove QueryPie webview session data"),
)
.subcommand(
clap::Command::new("status")
.about("Show current QueryPie authentication status"),
)
})
}
fn into_global_and_command(self) -> Result<(Global, Command)> {
let cfg = config::load(self.config.as_deref())?;
let mut global = Global {
host: pick_non_empty(self.host, cfg.host),
connection: cfg.connection,
engine: String::new(),
database: cfg.database,
schema: String::new(),
verbose: self.verbose,
};
self.command.apply_selection(&mut global);
Ok((global, self.command))
}
}
impl Global {
pub(in crate::cli) fn set_connection(&mut self, value: &Option<String>) {
replace_with_non_empty(&mut self.connection, value);
}
pub(in crate::cli) fn set_engine(&mut self, value: &Option<String>) {
replace_with(&mut self.engine, value);
}
pub(in crate::cli) fn set_database(&mut self, value: &Option<String>) {
replace_with_non_empty(&mut self.database, value);
}
pub(in crate::cli) fn set_schema(&mut self, value: &Option<String>) {
replace_with(&mut self.schema, value);
}
}
pub fn render_error(err: &anyhow::Error) {
if let Some(err) = err.downcast_ref::<AuthLoginFailed>() {
anstream::eprintln!("{} {}", style::error_icon(), err.message());
return;
}
if auth::is_login_canceled(err) {
anstream::eprintln!("{} {}", style::error_icon(), err);
return;
}
if err.downcast_ref::<AuthStatusFailed>().is_some() {
return;
}
if let Some(ge) = err.downcast_ref::<GrpcError>() {
eprintln!("error: {}", ge.message);
if let Some(hint) = ge.hint() {
eprintln!(" {hint}");
}
} else {
eprintln!("error: {err}");
}
}
#[derive(Debug)]
pub(super) struct AuthLoginFailed {
message: String,
}
impl AuthLoginFailed {
fn message(&self) -> &str {
&self.message
}
}
impl From<anyhow::Error> for AuthLoginFailed {
fn from(err: anyhow::Error) -> Self {
Self {
message: capitalize_first(&err.to_string()),
}
}
}
impl fmt::Display for AuthLoginFailed {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for AuthLoginFailed {}
#[derive(Debug)]
pub(super) struct AuthStatusFailed;
impl fmt::Display for AuthStatusFailed {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("auth status failed")
}
}
impl std::error::Error for AuthStatusFailed {}
fn pick_non_empty(flag: Option<String>, cfg: String) -> String {
flag.filter(|s| !s.trim().is_empty()).unwrap_or(cfg)
}
fn replace_with_non_empty(target: &mut String, value: &Option<String>) {
if let Some(value) = value.as_ref().filter(|s| !s.trim().is_empty()) {
target.clone_from(value);
}
}
fn replace_with(target: &mut String, value: &Option<String>) {
if let Some(value) = value {
target.clone_from(value);
}
}
fn capitalize_first(text: &str) -> String {
let mut chars = text.chars();
match chars.next() {
Some(first) => first.to_uppercase().chain(chars).collect(),
None => "Login failed".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::{error::ErrorKind, CommandFactory};
#[test]
fn clap_command_debug_asserts() {
Cli::command().debug_assert();
}
#[test]
fn subcommands_are_alphabetical() {
assert_subcommands_are_alphabetical(&Cli::command());
}
#[test]
fn options_are_alphabetical() {
let mut command = Cli::command();
command.build();
assert_options_are_alphabetical(&command);
}
#[test]
fn top_level_help_only_shows_global_options() {
let help = help_for(["querypie", "--help"]);
assert!(help.contains("--config <PATH>"));
assert!(!help.contains("--connection <CONNECTION>"));
assert!(!help.contains("--db <DATABASE>"));
assert!(!help.contains("--engine <ENGINE>"));
assert!(help.contains("--host <HOST>"));
assert!(!help.contains("--schema <SCHEMA>"));
}
#[test]
fn scoped_help_shows_selection_options_on_relevant_commands() {
let auth = help_for(["querypie", "auth", "--help"]);
assert!(!auth.contains("--connection"));
assert!(!auth.contains("--db"));
assert!(!auth.contains("--engine"));
assert!(!auth.contains("--schema"));
let database = help_for(["querypie", "database", "list", "--help"]);
assert!(database.contains("--connection <CONNECTION>"));
assert!(!database.contains("--db"));
assert!(database.contains("--engine <ENGINE>"));
assert!(!database.contains("--schema"));
let table = help_for(["querypie", "table", "list", "--help"]);
assert!(table.contains("--connection <CONNECTION>"));
assert!(table.contains("--db <DATABASE>"));
assert!(table.contains("--engine <ENGINE>"));
assert!(table.contains("--schema <SCHEMA>"));
let clear = help_for(["querypie", "session", "clear", "--help"]);
assert!(clear.contains("--connection <CONNECTION>"));
assert!(!clear.contains("--db"));
assert!(!clear.contains("--engine"));
assert!(!clear.contains("--schema"));
}
fn help_for<const N: usize>(args: [&str; N]) -> String {
let err = Cli::command().try_get_matches_from(args).unwrap_err();
assert_eq!(err.kind(), ErrorKind::DisplayHelp);
err.to_string()
}
fn assert_subcommands_are_alphabetical(command: &clap::Command) {
let actual = command
.get_subcommands()
.map(|subcommand| subcommand.get_name())
.collect::<Vec<_>>();
let mut expected = actual.clone();
expected.sort_unstable();
assert_eq!(actual, expected, "{} subcommands", command.get_name());
for subcommand in command.get_subcommands() {
assert_subcommands_are_alphabetical(subcommand);
}
}
fn assert_options_are_alphabetical(command: &clap::Command) {
let mut options = command
.get_arguments()
.filter_map(|argument| {
argument
.get_long()
.filter(|long| *long != "help")
.map(|long| (argument.get_display_order(), long))
})
.collect::<Vec<_>>();
options.sort_by_key(|(display_order, _)| *display_order);
let actual = options
.into_iter()
.map(|(_, long)| long)
.collect::<Vec<_>>();
let mut expected = actual.clone();
expected.sort_unstable();
assert_eq!(actual, expected, "{} options", command.get_name());
for subcommand in command.get_subcommands() {
assert_options_are_alphabetical(subcommand);
}
}
}