sabiql 2.0.0

A fast, driver-less TUI for browsing and editing PostgreSQL and SQLite databases
pub mod fixtures;
pub mod postgres;

use std::sync::Arc;
use std::time::Instant;

use ratatui::Terminal;
use ratatui::backend::Backend;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use ratatui::layout::Position;

use sabiql_app::model::app_state::AppState;
use sabiql_app::model::connection::setup::ConnectionField;
use sabiql_app::model::shared::text_input::TextInputState;
use sabiql_app::services::AppServices;
use sabiql_domain::{ConnectionId, DatabaseType};
use sabiql_ui::shell::layout::MainLayout;
use sabiql_ui::theme::{ThemePalette, palette_for};

pub const TEST_WIDTH: u16 = 165;
pub const TEST_HEIGHT: u16 = 51;

pub fn test_instant() -> Instant {
    Instant::now()
}

pub fn create_test_state() -> AppState {
    let mut state = AppState::new("test_project".to_string());
    state.session.set_active_connection_identity_for_test(
        &ConnectionId::from_string("test-connection"),
        "localhost:5432/test",
        DatabaseType::PostgreSQL,
    );
    state
}

pub fn focus_connection_field(state: &mut AppState, field: ConnectionField) {
    let fields = state.connection_setup.visible_fields();
    let target_idx = fields
        .iter()
        .position(|candidate| *candidate == field)
        .unwrap_or_else(|| panic!("field {field:?} is not visible: {fields:?}"));

    loop {
        let current = state.connection_setup.focused_field();
        if current == field {
            return;
        }
        let current_idx = fields
            .iter()
            .position(|candidate| *candidate == current)
            .expect("focused field must be visible");
        if target_idx > current_idx {
            state.connection_setup.focus_next_field();
        } else {
            state.connection_setup.focus_prev_field();
        }
    }
}

pub fn set_connection_input(state: &mut AppState, field: ConnectionField, input: TextInputState) {
    *state
        .connection_setup
        .input_mut(field)
        .expect("expected text input field") = input;
}

pub fn create_test_terminal() -> Terminal<TestBackend> {
    let backend = TestBackend::new(TEST_WIDTH, TEST_HEIGHT);
    Terminal::new(backend).unwrap()
}

pub fn create_test_terminal_sized(width: u16, height: u16) -> Terminal<TestBackend> {
    let backend = TestBackend::new(width, height);
    Terminal::new(backend).unwrap()
}

const FIXED_TIME_MS: u128 = 0;

pub fn render_and_get_buffer(terminal: &mut Terminal<TestBackend>, state: &mut AppState) -> Buffer {
    render_and_get_buffer_at(terminal, state, test_instant())
}

pub fn render_and_get_buffer_at(
    terminal: &mut Terminal<TestBackend>,
    state: &mut AppState,
    now: Instant,
) -> Buffer {
    render_and_get_buffer_at_with_theme(terminal, state, now, palette_for(state.ui.theme_id()))
}

pub fn render_and_get_buffer_at_with_theme(
    terminal: &mut Terminal<TestBackend>,
    state: &mut AppState,
    now: Instant,
    theme: &ThemePalette,
) -> Buffer {
    render_and_get_buffer_at_with_theme_and_services(
        terminal,
        state,
        now,
        theme,
        &AppServices::stub(),
    )
}

pub fn render_and_get_buffer_at_with_theme_and_services(
    terminal: &mut Terminal<TestBackend>,
    state: &mut AppState,
    now: Instant,
    theme: &ThemePalette,
    services: &AppServices,
) -> Buffer {
    terminal
        .draw(|frame| {
            let output = MainLayout::render_with_theme(
                frame,
                state,
                Some(FIXED_TIME_MS),
                services,
                now,
                theme,
            );
            state.apply_render_output(output);
        })
        .unwrap();

    terminal.backend().buffer().clone()
}

pub fn render_to_string(terminal: &mut Terminal<TestBackend>, state: &mut AppState) -> String {
    let buffer = render_and_get_buffer(terminal, state);
    buffer_to_string(&buffer)
}

pub fn render_to_string_with_services(
    terminal: &mut Terminal<TestBackend>,
    state: &mut AppState,
    services: &AppServices,
) -> String {
    let buffer = render_and_get_buffer_at_with_theme_and_services(
        terminal,
        state,
        test_instant(),
        palette_for(state.ui.theme_id()),
        services,
    );
    buffer_to_string(&buffer)
}

pub fn render_and_get_cursor_position(
    terminal: &mut Terminal<TestBackend>,
    state: &mut AppState,
) -> Position {
    let _ = render_and_get_buffer(terminal, state);
    terminal.backend_mut().get_cursor_position().unwrap()
}

fn buffer_to_string(buffer: &Buffer) -> String {
    let mut result = String::new();
    for y in 0..buffer.area.height {
        for x in 0..buffer.area.width {
            let cell = buffer.cell((x, y)).unwrap();
            result.push_str(cell.symbol());
        }
        if y < buffer.area.height - 1 {
            result.push('\n');
        }
    }
    result
}

pub fn connected_state() -> AppState {
    let mut state = create_test_state();
    state
        .session
        .mark_connected(Arc::new(fixtures::sample_metadata()));
    state
}

pub fn explorer_selected_state() -> AppState {
    let mut state = connected_state();
    state.ui.set_explorer_selection(Some(0));
    state
}

pub fn table_detail_loaded_state() -> AppState {
    let mut state = explorer_selected_state();
    let _ = state
        .session
        .set_table_detail(fixtures::sample_table_detail(), 0);
    state
}

pub fn with_current_result(state: &mut AppState) {
    state
        .query
        .set_current_result(Arc::new(fixtures::sample_query_result()));
}