sabiql 1.7.0

A fast, driver-less TUI for browsing and editing PostgreSQL databases
Documentation
use std::cell::RefCell;
use std::sync::Arc;
use std::time::{Duration, Instant};

use clap::Parser;
use color_eyre::eyre::Result;
use tokio::sync::mpsc;
use tokio::time::sleep_until;

use sabiql::app::action::Action;
use sabiql::app::cache::TtlCache;
use sabiql::app::completion::CompletionEngine;
use sabiql::app::effect::Effect;
use sabiql::app::effect_runner::EffectRunner;
use sabiql::app::input_mode::InputMode;
use sabiql::app::ports::{
    ConnectionStore, ConnectionStoreError, ServiceFileError, ServiceFileReader,
};
use sabiql::app::reducer::reduce;
use sabiql::app::render_schedule::next_animation_deadline;
use sabiql::app::state::AppState;
use sabiql::error;
use sabiql::infra::adapters::{
    FileConfigWriter, FsErLogWriter, PgServiceFileReader, PostgresAdapter, TomlConnectionStore,
};
use sabiql::infra::config::project_root::{find_project_root, get_project_name};
use sabiql::infra::export::DotExporter;
use sabiql::ui::adapters::TuiAdapter;
use sabiql::ui::event::handler::handle_event;
use sabiql::ui::tui::TuiRunner;

#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
    #[command(subcommand)]
    command: Option<Command>,
}

#[derive(clap::Subcommand, Debug)]
enum Command {
    #[cfg(feature = "self-update")]
    /// Update sabiql to the latest compatible version
    Update,
    #[cfg(not(feature = "self-update"))]
    #[command(hide = true)]
    /// Self-update is disabled in this build
    Update,
}

#[tokio::main]
async fn main() -> Result<()> {
    dotenvy::dotenv().ok();
    error::install_hooks()?;

    let args = Args::parse();
    if let Some(Command::Update) = args.command {
        #[cfg(feature = "self-update")]
        {
            return run_update();
        }
        #[cfg(not(feature = "self-update"))]
        {
            eprintln!("{}", self_update_disabled_message());
            std::process::exit(1);
        }
    }

    let project_root = find_project_root()?;
    let project_name = get_project_name(&project_root);

    let (action_tx, mut action_rx) = mpsc::channel::<Action>(256);

    let adapter = Arc::new(PostgresAdapter::new());
    let metadata_cache = TtlCache::new(300);
    let completion_engine = RefCell::new(CompletionEngine::new());
    let connection_store = TomlConnectionStore::new()?;
    let all_profiles = connection_store.load_all();
    let connection_store = Arc::new(connection_store);

    let service_file_reader: Arc<dyn ServiceFileReader> = Arc::new(PgServiceFileReader::new());

    let effect_runner = EffectRunner::builder()
        .metadata_provider(Arc::clone(&adapter) as _)
        .query_executor(Arc::clone(&adapter) as _)
        .dsn_builder(Arc::clone(&adapter) as _)
        .er_exporter(Arc::new(DotExporter::new()))
        .config_writer(Arc::new(FileConfigWriter::new()))
        .er_log_writer(Arc::new(FsErLogWriter))
        .connection_store(Arc::clone(&connection_store) as _)
        .service_file_reader(Arc::clone(&service_file_reader))
        .metadata_cache(metadata_cache.clone())
        .action_tx(action_tx.clone())
        .build();

    let mut state = AppState::with_ports(
        project_name,
        Arc::clone(&adapter) as _,
        Arc::clone(&adapter) as _,
    );

    match all_profiles {
        Ok(profiles) if profiles.is_empty() => {
            load_service_entries(&mut state, &*service_file_reader);
            if state.service_entries().is_empty() {
                state.connection_setup.is_first_run = true;
                state.ui.input_mode = InputMode::ConnectionSetup;
            } else {
                state.ui.input_mode = InputMode::ConnectionSelector;
                state.ui.set_connection_list_selection(Some(0));
            }
        }
        Ok(mut profiles) => {
            profiles.sort_by(|a, b| {
                a.display_name()
                    .to_lowercase()
                    .cmp(&b.display_name().to_lowercase())
            });
            state.set_connections(profiles);
            load_service_entries(&mut state, &*service_file_reader);

            state.ui.input_mode = InputMode::ConnectionSelector;
            state.ui.set_connection_list_selection(Some(0));
        }
        Err(ConnectionStoreError::VersionMismatch { found, expected }) => {
            eprintln!(
                "Error: Configuration file version mismatch (found v{}, expected v{}).\n\
                 Please delete {} and reconfigure.",
                found,
                expected,
                connection_store.storage_path().display()
            );
            std::process::exit(1);
        }
        Err(_) => {
            state.connection_setup.is_first_run = true;
            state.ui.input_mode = InputMode::ConnectionSetup;
        }
    }

    state.action_tx = Some(action_tx.clone());

    let mut tui = TuiRunner::new()?;
    tui.enter()?;

    let initial_size = tui.terminal().size()?;
    state.ui.terminal_height = initial_size.height;

    // TryConnect is idempotent, so safe even if called multiple times
    if state.runtime.dsn.is_some() && state.ui.input_mode == InputMode::Normal {
        let _ = action_tx.send(Action::TryConnect).await;
    }

    let cache_cleanup_interval = Duration::from_secs(150);
    let mut last_cache_cleanup = Instant::now();

    loop {
        let now = Instant::now();
        let deadline = next_animation_deadline(&state, now);

        tokio::select! {
            Some(event) = tui.next_event() => {
                let action = handle_event(event, &state);
                if !action.is_none() {
                    let _ = action_tx.send(action).await;
                }
            }
            Some(action) = action_rx.recv() => {
                let now = Instant::now();
                let mut effects = reduce(&mut state, action, now);

                if state.render_dirty {
                    state.clear_expired_timers(now);
                    effects.push(Effect::Render);
                }

                let mut tui_adapter = TuiAdapter::new(&mut tui);
                effect_runner.run(effects, &mut tui_adapter, &mut state, &completion_engine).await?;
                state.clear_dirty();
            }
            // Animation deadline reached (spinner, cursor blink, message timeout)
            _ = async {
                match deadline {
                    Some(d) => sleep_until(d.into()).await,
                    None => std::future::pending::<()>().await,
                }
            } => {
                let now = Instant::now();
                state.clear_expired_timers(now);
                let effects = reduce(&mut state, Action::Render, now);
                let mut tui_adapter = TuiAdapter::new(&mut tui);
                effect_runner.run(effects, &mut tui_adapter, &mut state, &completion_engine).await?;
                state.clear_dirty();
            }
        }

        if let Some(debounce_until) = state.sql_modal.completion_debounce
            && Instant::now() >= debounce_until
        {
            state.sql_modal.completion_debounce = None;
            let _ = action_tx.send(Action::CompletionTrigger).await;
        }

        if last_cache_cleanup.elapsed() >= cache_cleanup_interval {
            metadata_cache.cleanup_expired().await;
            last_cache_cleanup = Instant::now();
        }

        if state.should_quit {
            break;
        }
    }

    tui.exit()?;
    Ok(())
}

fn load_service_entries(state: &mut AppState, reader: &dyn ServiceFileReader) {
    match reader.read_services() {
        Ok((services, path)) if !services.is_empty() => {
            state.set_service_entries(services);
            state.runtime.service_file_path = Some(path);
        }
        Ok(_) => {}
        Err(ServiceFileError::NotFound(_)) => {}
        Err(e) => {
            state.messages.set_error(e.to_string());
        }
    }
}

#[cfg(feature = "self-update")]
fn run_update() -> Result<()> {
    let current = env!("CARGO_PKG_VERSION");
    println!("Current version: v{}", current);
    println!("Checking for updates...");

    let status = self_update::backends::github::Update::configure()
        .repo_owner("riii111")
        .repo_name("sabiql")
        .bin_name("sabiql")
        .show_download_progress(true)
        .no_confirm(true)
        .current_version(current)
        .build()?
        .update()?;

    if status.updated() {
        println!("Updated successfully: v{} -> {}", current, status.version());
    } else {
        println!("Already up to date (v{}).", current);
    }

    Ok(())
}

#[cfg(not(feature = "self-update"))]
fn self_update_disabled_message() -> String {
    format!(
        "Self-update is not available in this build (v{}).\n\
         If installed via Homebrew: brew upgrade sabiql\n\
         If installed via cargo:    cargo install sabiql",
        env!("CARGO_PKG_VERSION")
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use clap::Parser;

    #[test]
    fn no_subcommand_returns_none() {
        let args = Args::parse_from(["sabiql"]);
        assert!(args.command.is_none());
    }

    #[test]
    #[cfg(feature = "self-update")]
    fn update_subcommand_is_recognized() {
        let args = Args::parse_from(["sabiql", "update"]);
        assert!(matches!(args.command, Some(Command::Update)));
    }

    #[test]
    #[cfg(not(feature = "self-update"))]
    fn update_subcommand_available_but_self_update_disabled() {
        let args = Args::parse_from(["sabiql", "update"]);
        assert!(matches!(args.command, Some(Command::Update)));
    }

    #[test]
    #[cfg(not(feature = "self-update"))]
    fn disabled_message_contains_version_and_upgrade_guidance() {
        let msg = self_update_disabled_message();
        assert!(msg.contains(env!("CARGO_PKG_VERSION")));
        assert!(msg.contains("brew upgrade sabiql"));
        assert!(msg.contains("cargo install sabiql"));
    }
}