tuible 0.0.2-alpha.1

A keyboard-driven database client for your terminal, built for both humans and AI agents.
mod cli;
mod config;
mod db;
mod history;
mod operation;
mod tui;

use std::path::PathBuf;

use clap::Parser;

use cli::{Cli, Command, DynamoCommand, DynamoConnectionArgs};
use config::{Config, ConnectionProfile};
use db::DataSource;
use db::dynamodb::{DynamoOptions, DynamoQueryOptions, DynamoScanOptions, DynamoSource};
use db::model::QueryOutcome;
use db::sqlite::{SqliteSource, ensure_demo_db};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();

    match cli.command {
        Some(Command::Demo { reset }) => run_demo(reset).await?,
        None => run_startup().await?,
        Some(Command::Open { database, write }) => {
            let source = open_source(&database, !write).await?;
            tui::run(source).await?;
        }
        Some(Command::Profile { name }) => {
            let config = Config::load()?;
            let profile = config
                .profile(&name)
                .cloned()
                .ok_or_else(|| anyhow::anyhow!("unknown connection profile `{name}`"))?;
            run_profile(profile).await?;
        }
        Some(Command::Config { show }) => {
            if show {
                println!("{}", toml::to_string_pretty(&Config::load()?)?);
            } else {
                println!("{}", config::config_path()?.display());
            }
        }
        Some(Command::Tables { database }) => {
            let source = open_source(&database, true).await?;
            println!(
                "{}",
                serde_json::to_string_pretty(&source.list_tables().await?)?
            );
        }
        Some(Command::Schema { database, table }) => {
            let source = open_source(&database, true).await?;
            let columns = source.table_schema(&table).await?;
            let output: Vec<_> = columns
                .into_iter()
                .map(|column| {
                    serde_json::json!({
                        "name": column.name,
                        "type": column.col_type,
                        "not_null": column.notnull,
                        "primary_key": column.pk,
                    })
                })
                .collect();
            println!("{}", serde_json::to_string_pretty(&output)?);
        }
        Some(Command::Query {
            database,
            sql,
            max_rows,
        }) => {
            let source = open_source(&database, true).await?;
            print_outcome(source.execute_sql(&sql, max_rows).await?)?;
        }
        Some(Command::Execute {
            database,
            sql,
            yes,
            max_rows,
        }) => {
            anyhow::ensure!(yes, "refusing write-capable execution without --yes");
            let source = open_source(&database, false).await?;
            print_outcome(source.execute_sql(&sql, max_rows).await?)?;
        }
        Some(Command::Dynamodb(command)) => run_dynamodb(*command).await?,
    }

    Ok(())
}

async fn run_dynamodb(command: DynamoCommand) -> anyhow::Result<()> {
    match command {
        DynamoCommand::Open { connection, write } => {
            let source = open_dynamodb(connection, !write).await?;
            tui::run(DataSource::Dynamo(source)).await?;
        }
        DynamoCommand::Tables { connection } => {
            let source = open_dynamodb(connection, true).await?;
            print_json(&serde_json::json!({ "tables": source.list_tables().await? }))?;
        }
        DynamoCommand::Describe { table, connection } => {
            let source = open_dynamodb(connection, true).await?;
            print_json(&source.describe_table_json(&table).await?)?;
        }
        DynamoCommand::Scan {
            table,
            filter,
            projection,
            values,
            names,
            start_key,
            limit,
            consistent_read,
            typed_json,
            connection,
        } => {
            let source = open_dynamodb(connection, true).await?;
            let options = DynamoScanOptions {
                table,
                filter,
                projection,
                values: values.as_deref().map(parse_json_input).transpose()?,
                names: names.as_deref().map(parse_json_input).transpose()?,
                start_key: start_key.as_deref().map(parse_json_input).transpose()?,
                limit,
                consistent_read,
                typed_json,
            };
            print_json(&source.scan_json(options).await?)?;
        }
        DynamoCommand::Partiql {
            statement,
            parameters,
            typed_json,
            max_rows,
            next_token,
            yes,
            connection,
        } => {
            let source = open_dynamodb(connection, !yes).await?;
            let parameters = parameters.as_deref().map(parse_json_input).transpose()?;
            print_outcome(
                source
                    .execute_partiql(
                        &statement,
                        max_rows,
                        parameters.as_ref(),
                        typed_json,
                        next_token,
                    )
                    .await?,
            )?;
        }
        DynamoCommand::Get {
            table,
            key,
            projection,
            names,
            consistent_read,
            typed_json,
            connection,
        } => {
            let source = open_dynamodb(connection, true).await?;
            let key = parse_json_input(&key)?;
            let names = names.as_deref().map(parse_json_input).transpose()?;
            print_json(
                &source
                    .get_item_json(
                        &table,
                        &key,
                        consistent_read,
                        typed_json,
                        projection,
                        names.as_ref(),
                    )
                    .await?,
            )?;
        }
        DynamoCommand::Query {
            table,
            key_condition,
            index,
            filter,
            projection,
            values,
            names,
            start_key,
            limit,
            consistent_read,
            typed_json,
            descending,
            connection,
        } => {
            let source = open_dynamodb(connection, true).await?;
            let options = DynamoQueryOptions {
                table,
                key_condition,
                index,
                filter,
                projection,
                values: values.as_deref().map(parse_json_input).transpose()?,
                names: names.as_deref().map(parse_json_input).transpose()?,
                start_key: start_key.as_deref().map(parse_json_input).transpose()?,
                limit,
                consistent_read,
                typed_json,
                scan_forward: !descending,
            };
            print_json(&source.query_json(options).await?)?;
        }
        DynamoCommand::Put {
            table,
            item,
            condition,
            values,
            names,
            typed_json,
            yes,
            connection,
        } => {
            anyhow::ensure!(yes, "refusing DynamoDB put without --yes");
            let source = open_dynamodb(connection, false).await?;
            let item = parse_json_input(&item)?;
            let values = values.as_deref().map(parse_json_input).transpose()?;
            let names = names.as_deref().map(parse_json_input).transpose()?;
            source
                .put_item_json(
                    &table,
                    &item,
                    typed_json,
                    condition,
                    values.as_ref(),
                    names.as_ref(),
                )
                .await?;
            print_json(&serde_json::json!({ "ok": true }))?;
        }
        DynamoCommand::Delete {
            table,
            key,
            condition,
            values,
            names,
            typed_json,
            yes,
            connection,
        } => {
            anyhow::ensure!(yes, "refusing DynamoDB delete without --yes");
            let source = open_dynamodb(connection, false).await?;
            let key = parse_json_input(&key)?;
            let values = values.as_deref().map(parse_json_input).transpose()?;
            let names = names.as_deref().map(parse_json_input).transpose()?;
            source
                .delete_item_json(
                    &table,
                    &key,
                    typed_json,
                    condition,
                    values.as_ref(),
                    names.as_ref(),
                )
                .await?;
            print_json(&serde_json::json!({ "ok": true }))?;
        }
    }
    Ok(())
}

async fn run_startup() -> anyhow::Result<()> {
    let mut config = Config::load()?;
    match tui::startup::run(&mut config)? {
        Some(tui::startup::StartupChoice::Profile(profile)) => run_profile(profile).await?,
        Some(tui::startup::StartupChoice::Demo) => run_demo(false).await?,
        None => {}
    }
    Ok(())
}

async fn run_profile(profile: ConnectionProfile) -> anyhow::Result<()> {
    match profile {
        ConnectionProfile::Sqlite { path, write, .. } => {
            tui::run(open_source(&path, !write).await?).await?;
        }
        ConnectionProfile::Dynamodb {
            aws_profile,
            region,
            endpoint_url,
            local,
            write,
            ..
        } => {
            let source = open_dynamodb(
                DynamoConnectionArgs {
                    profile: aws_profile,
                    region,
                    endpoint_url,
                    local,
                },
                !write,
            )
            .await?;
            tui::run(DataSource::Dynamo(source)).await?;
        }
    }
    Ok(())
}

async fn open_dynamodb(
    connection: DynamoConnectionArgs,
    read_only: bool,
) -> anyhow::Result<DynamoSource> {
    Ok(DynamoSource::connect(DynamoOptions {
        profile: connection.profile,
        region: connection.region,
        endpoint_url: connection.endpoint_url,
        read_only,
        local: connection.local,
    })
    .await?)
}

fn parse_json_input(input: &str) -> anyhow::Result<serde_json::Value> {
    let content = if input == "-" {
        let mut content = String::new();
        std::io::Read::read_to_string(&mut std::io::stdin(), &mut content)?;
        content
    } else if let Some(path) = input.strip_prefix('@') {
        std::fs::read_to_string(path)?
    } else {
        input.to_string()
    };
    Ok(serde_json::from_str(&content)?)
}

fn print_json(value: &serde_json::Value) -> anyhow::Result<()> {
    println!("{}", serde_json::to_string_pretty(value)?);
    Ok(())
}

async fn run_demo(reset: bool) -> anyhow::Result<()> {
    let path = demo_db_path()?;
    if reset && path.exists() {
        std::fs::remove_file(&path)?;
    }
    ensure_demo_db(&path).await?;
    let source = DataSource::Sqlite(SqliteSource::connect(&path).await?);
    tui::run(source).await?;
    Ok(())
}

async fn open_source(path: &std::path::Path, read_only: bool) -> anyhow::Result<DataSource> {
    anyhow::ensure!(path.exists(), "database does not exist: {}", path.display());
    let source = if read_only {
        SqliteSource::connect_read_only(path).await?
    } else {
        SqliteSource::connect(path).await?
    };
    Ok(DataSource::Sqlite(source))
}

fn print_outcome(outcome: QueryOutcome) -> anyhow::Result<()> {
    let output = match outcome {
        QueryOutcome::Rows {
            columns,
            rows,
            truncated,
            next_token,
            read_only: _,
        } => serde_json::json!({
            "columns": columns.into_iter().map(|column| column.name).collect::<Vec<_>>(),
            "rows": rows
                .into_iter()
                .map(|row| row.values.into_iter().map(|value| value.to_json()).collect::<Vec<_>>())
                .collect::<Vec<_>>(),
            "truncated": truncated,
            "next_token": next_token,
        }),
        QueryOutcome::Affected(rows_affected) => serde_json::json!({
            "rows_affected": rows_affected,
        }),
        QueryOutcome::Executed => serde_json::json!({ "ok": true }),
    };
    println!("{}", serde_json::to_string_pretty(&output)?);
    Ok(())
}

fn demo_db_path() -> anyhow::Result<PathBuf> {
    Ok(config::data_dir()?.join("demo.sqlite"))
}