sabiql 1.8.1

A fast, driver-less TUI for browsing and editing PostgreSQL databases
Documentation
use std::time::Instant;

use crate::app::action::Action;
use crate::app::effect::Effect;
use crate::app::input_mode::InputMode;
use crate::app::state::AppState;
use crate::app::write_update::build_pk_pairs;

use super::super::helpers::editable_preview_base;

fn editable_cell_context(state: &AppState) -> Result<(usize, usize, String), String> {
    let row_idx = state
        .result_interaction
        .selection()
        .row()
        .ok_or_else(|| "No active row".to_string())?;
    let col_idx = state
        .result_interaction
        .selection()
        .cell()
        .ok_or_else(|| "No active cell".to_string())?;

    let (result, pk_cols) = editable_preview_base(state)?;

    let column_name = result
        .columns
        .get(col_idx)
        .ok_or_else(|| "Column index out of bounds".to_string())?;
    if pk_cols.iter().any(|pk| pk == column_name) {
        return Err("Primary key columns are read-only".to_string());
    }

    let row = result
        .rows
        .get(row_idx)
        .ok_or_else(|| "Row index out of bounds".to_string())?;
    if build_pk_pairs(&result.columns, row, pk_cols).is_none() {
        return Err("Stable key columns are not present in current result".to_string());
    }

    let cell_value = row
        .get(col_idx)
        .ok_or_else(|| "Cell index out of bounds".to_string())?
        .clone();

    Ok((row_idx, col_idx, cell_value))
}

pub fn reduce(state: &mut AppState, action: &Action, now: Instant) -> Option<Vec<Effect>> {
    match action {
        Action::ResultEnterCellEdit => {
            if state.session.read_only {
                state
                    .messages
                    .set_error_at("Read-only mode: editing is disabled".to_string(), now);
                return Some(vec![]);
            }
            match editable_cell_context(state) {
                Ok((row_idx, col_idx, value)) => {
                    if state.result_interaction.cell_edit().row != Some(row_idx)
                        || state.result_interaction.cell_edit().col != Some(col_idx)
                    {
                        state
                            .result_interaction
                            .begin_cell_edit(row_idx, col_idx, value);
                        state.result_interaction.clear_write_preview();
                    }
                    state.modal.set_mode(InputMode::CellEdit);
                    Some(vec![])
                }
                Err(reason) => {
                    state.messages.set_error_at(reason, now);
                    Some(vec![])
                }
            }
        }
        Action::ResultCancelCellEdit => {
            state.result_interaction.clear_write_preview();
            state.modal.set_mode(InputMode::Normal);
            Some(vec![])
        }
        Action::ResultDiscardCellEdit => {
            state.result_interaction.discard_cell_edit();
            state.modal.set_mode(InputMode::Normal);
            Some(vec![])
        }
        Action::ResultCellEditInput(c) => {
            state
                .result_interaction
                .cell_edit_input_mut()
                .insert_char(*c);
            Some(vec![])
        }
        Action::ResultCellEditBackspace => {
            state.result_interaction.cell_edit_input_mut().backspace();
            Some(vec![])
        }
        Action::ResultCellEditDelete => {
            state.result_interaction.cell_edit_input_mut().delete();
            Some(vec![])
        }
        Action::ResultCellEditMoveCursor(m) => {
            state
                .result_interaction
                .cell_edit_input_mut()
                .move_cursor(*m);
            Some(vec![])
        }
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app::action::CursorMove;
    use crate::domain::{QueryResult, QuerySource, Table};
    use std::sync::Arc;

    mod cell_edit_entry_guardrails {
        use super::*;

        pub(super) fn minimal_users_table() -> Table {
            Table {
                schema: "public".to_string(),
                name: "users".to_string(),
                owner: None,
                columns: vec![],
                primary_key: Some(vec!["id".to_string()]),
                foreign_keys: vec![],
                indexes: vec![],
                rls: None,
                triggers: vec![],
                row_count_estimate: None,
                comment: None,
            }
        }

        pub(super) fn preview_state_with_selection() -> AppState {
            let mut state = AppState::new("test".to_string());
            state.query.set_current_result(Arc::new(QueryResult {
                query: String::new(),
                columns: vec!["id".to_string(), "name".to_string()],
                rows: vec![vec!["1".to_string(), "alice".to_string()]],
                row_count: 1,
                execution_time_ms: 1,
                executed_at: Instant::now(),
                source: QuerySource::Preview,
                error: None,
                command_tag: None,
            }));
            state.query.pagination.schema = "public".to_string();
            state.query.pagination.table = "users".to_string();
            state.result_interaction.enter_row(0);
            state.result_interaction.enter_cell(1);
            state
        }

        #[test]
        fn re_entering_same_cell_with_pending_draft_preserves_draft() {
            let mut state = preview_state_with_selection();
            state
                .session
                .set_table_detail_raw(Some(minimal_users_table()));
            state
                .result_interaction
                .begin_cell_edit(0, 1, "alice".to_string());
            state
                .result_interaction
                .cell_edit_input_mut()
                .set_content("modified".to_string());
            state.modal.set_mode(InputMode::Normal);

            reduce(&mut state, &Action::ResultEnterCellEdit, Instant::now()).unwrap();

            assert_eq!(state.input_mode(), InputMode::CellEdit);
            assert_eq!(
                state.result_interaction.cell_edit().draft_value(),
                "modified"
            );
        }

        #[test]
        fn entering_different_cell_resets_draft() {
            let mut state = preview_state_with_selection();
            state
                .session
                .set_table_detail_raw(Some(minimal_users_table()));
            state
                .result_interaction
                .begin_cell_edit(0, 99, "stale".to_string());
            state
                .result_interaction
                .cell_edit_input_mut()
                .set_content("stale-modified".to_string());

            reduce(&mut state, &Action::ResultEnterCellEdit, Instant::now()).unwrap();

            assert_eq!(state.input_mode(), InputMode::CellEdit);
            assert_eq!(state.result_interaction.cell_edit().col, Some(1));
            assert_eq!(state.result_interaction.cell_edit().draft_value(), "alice");
        }

        #[test]
        fn stale_table_detail_blocks_cell_edit_entry() {
            let mut state = preview_state_with_selection();
            state.session.set_table_detail_raw(Some(Table {
                schema: "public".to_string(),
                name: "posts".to_string(),
                owner: None,
                columns: vec![],
                primary_key: Some(vec!["id".to_string()]),
                foreign_keys: vec![],
                indexes: vec![],
                rls: None,
                triggers: vec![],
                row_count_estimate: None,
                comment: None,
            }));

            let effects = reduce(&mut state, &Action::ResultEnterCellEdit, Instant::now()).unwrap();

            assert!(effects.is_empty());
            assert_eq!(state.input_mode(), InputMode::Normal);
            assert_eq!(
                state.messages.last_error.as_deref(),
                Some("Table metadata does not match current preview target")
            );
        }
    }

    mod read_only_guard {
        use super::*;

        #[test]
        fn read_only_blocks_cell_edit_entry() {
            let mut state = cell_edit_entry_guardrails::preview_state_with_selection();
            state
                .session
                .set_table_detail_raw(Some(cell_edit_entry_guardrails::minimal_users_table()));
            state.session.read_only = true;

            let effects = reduce(&mut state, &Action::ResultEnterCellEdit, Instant::now()).unwrap();

            assert!(effects.is_empty());
            assert_eq!(state.input_mode(), InputMode::Normal);
            assert!(state.messages.last_error.is_some());
        }
    }

    mod cell_edit_cursor_ops {
        use super::*;

        fn state_in_cell_edit(content: &str, cursor: usize) -> AppState {
            let mut state = AppState::new("test".to_string());
            state.modal.set_mode(InputMode::CellEdit);
            state
                .result_interaction
                .begin_cell_edit(0, 0, content.to_string());
            state
                .result_interaction
                .cell_edit_input_mut()
                .set_cursor(cursor);
            state
        }

        #[test]
        fn delete_removes_char_at_cursor() {
            let mut state = state_in_cell_edit("abcd", 1);

            reduce(&mut state, &Action::ResultCellEditDelete, Instant::now());

            assert_eq!(state.result_interaction.cell_edit().draft_value(), "acd");
            assert_eq!(state.result_interaction.cell_edit().input.cursor(), 1);
        }

        #[test]
        fn delete_at_end_is_noop() {
            let mut state = state_in_cell_edit("abc", 3);

            reduce(&mut state, &Action::ResultCellEditDelete, Instant::now());

            assert_eq!(state.result_interaction.cell_edit().draft_value(), "abc");
        }

        #[test]
        fn move_cursor_left_decrements() {
            let mut state = state_in_cell_edit("abc", 2);

            reduce(
                &mut state,
                &Action::ResultCellEditMoveCursor(CursorMove::Left),
                Instant::now(),
            );

            assert_eq!(state.result_interaction.cell_edit().input.cursor(), 1);
        }

        #[test]
        fn move_cursor_right_increments() {
            let mut state = state_in_cell_edit("abc", 1);

            reduce(
                &mut state,
                &Action::ResultCellEditMoveCursor(CursorMove::Right),
                Instant::now(),
            );

            assert_eq!(state.result_interaction.cell_edit().input.cursor(), 2);
        }

        #[test]
        fn move_cursor_home_jumps_to_start() {
            let mut state = state_in_cell_edit("abc", 3);

            reduce(
                &mut state,
                &Action::ResultCellEditMoveCursor(CursorMove::Home),
                Instant::now(),
            );

            assert_eq!(state.result_interaction.cell_edit().input.cursor(), 0);
        }

        #[test]
        fn move_cursor_end_jumps_to_end() {
            let mut state = state_in_cell_edit("abc", 0);

            reduce(
                &mut state,
                &Action::ResultCellEditMoveCursor(CursorMove::End),
                Instant::now(),
            );

            assert_eq!(state.result_interaction.cell_edit().input.cursor(), 3);
        }

        #[test]
        fn input_inserts_at_cursor_not_at_end() {
            let mut state = state_in_cell_edit("ac", 1);

            reduce(
                &mut state,
                &Action::ResultCellEditInput('b'),
                Instant::now(),
            );

            assert_eq!(state.result_interaction.cell_edit().draft_value(), "abc");
            assert_eq!(state.result_interaction.cell_edit().input.cursor(), 2);
        }

        #[test]
        fn backspace_removes_char_before_cursor() {
            let mut state = state_in_cell_edit("abc", 2);

            reduce(&mut state, &Action::ResultCellEditBackspace, Instant::now());

            assert_eq!(state.result_interaction.cell_edit().draft_value(), "ac");
            assert_eq!(state.result_interaction.cell_edit().input.cursor(), 1);
        }
    }
}