querypie-cli 0.2.0

Query QueryPie databases from the terminal with webview authentication.
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 {
        // clap_complete includes hidden subcommands, so rebuild auth without internal cookie helpers.
        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);
        }
    }
}