sabiql 1.8.2

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

use crate::domain::connection::{ConnectionId, ConnectionProfile, SslMode};

pub const CONNECTION_INPUT_WIDTH: u16 = 30;
pub const CONNECTION_INPUT_VISIBLE_WIDTH: usize = (CONNECTION_INPUT_WIDTH - 4) as usize;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ConnectionField {
    Name,
    Host,
    Port,
    Database,
    User,
    Password,
    SslMode,
}

impl ConnectionField {
    pub fn all() -> &'static [ConnectionField] {
        &[
            ConnectionField::Name,
            ConnectionField::Host,
            ConnectionField::Port,
            ConnectionField::Database,
            ConnectionField::User,
            ConnectionField::Password,
            ConnectionField::SslMode,
        ]
    }

    pub fn next(&self) -> Option<ConnectionField> {
        match self {
            ConnectionField::Name => Some(ConnectionField::Host),
            ConnectionField::Host => Some(ConnectionField::Port),
            ConnectionField::Port => Some(ConnectionField::Database),
            ConnectionField::Database => Some(ConnectionField::User),
            ConnectionField::User => Some(ConnectionField::Password),
            ConnectionField::Password => Some(ConnectionField::SslMode),
            ConnectionField::SslMode => None,
        }
    }

    pub fn prev(&self) -> Option<ConnectionField> {
        match self {
            ConnectionField::Name => None,
            ConnectionField::Host => Some(ConnectionField::Name),
            ConnectionField::Port => Some(ConnectionField::Host),
            ConnectionField::Database => Some(ConnectionField::Port),
            ConnectionField::User => Some(ConnectionField::Database),
            ConnectionField::Password => Some(ConnectionField::User),
            ConnectionField::SslMode => Some(ConnectionField::Password),
        }
    }

    pub fn is_required(&self) -> bool {
        matches!(
            self,
            ConnectionField::Name
                | ConnectionField::Host
                | ConnectionField::Port
                | ConnectionField::Database
                | ConnectionField::User
        )
    }

    pub fn label(&self) -> &'static str {
        match self {
            ConnectionField::Name => "Name:",
            ConnectionField::Host => "Host:",
            ConnectionField::Port => "Port:",
            ConnectionField::Database => "Database:",
            ConnectionField::User => "User:",
            ConnectionField::Password => "Password:",
            ConnectionField::SslMode => "SSL Mode:",
        }
    }
}

#[derive(Debug, Clone, Default)]
pub struct SslModeDropdown {
    pub is_open: bool,
    pub selected_index: usize,
}

#[derive(Debug, Clone)]
pub struct ConnectionSetupState {
    pub name: String,
    pub host: String,
    pub port: String,
    pub database: String,
    pub user: String,
    pub password: String,
    pub ssl_mode: SslMode,

    pub focused_field: ConnectionField,
    pub ssl_dropdown: SslModeDropdown,
    pub validation_errors: HashMap<ConnectionField, String>,

    pub cursor_position: usize,
    pub viewport_offset: usize,

    pub is_first_run: bool,

    pub editing_id: Option<ConnectionId>,
}

impl Default for ConnectionSetupState {
    fn default() -> Self {
        Self {
            name: String::new(),
            host: "localhost".to_string(),
            port: "5432".to_string(),
            database: String::new(),
            user: String::new(),
            password: String::new(),
            ssl_mode: SslMode::Prefer,
            focused_field: ConnectionField::Name,
            ssl_dropdown: SslModeDropdown::default(),
            validation_errors: HashMap::new(),
            cursor_position: 0,
            viewport_offset: 0,
            is_first_run: true,
            editing_id: None,
        }
    }
}

impl ConnectionSetupState {
    pub fn default_name(&self) -> String {
        if self.database.is_empty() {
            self.host.clone()
        } else {
            format!("{}@{}", self.database, self.host)
        }
    }

    pub fn field_value(&self, field: ConnectionField) -> &str {
        match field {
            ConnectionField::Name => &self.name,
            ConnectionField::Host => &self.host,
            ConnectionField::Port => &self.port,
            ConnectionField::Database => &self.database,
            ConnectionField::User => &self.user,
            ConnectionField::Password => &self.password,
            ConnectionField::SslMode => "",
        }
    }

    pub fn clear_errors(&mut self) {
        self.validation_errors.clear();
    }

    pub fn reset(&mut self) {
        *self = Self::default();
    }

    pub fn has_errors(&self) -> bool {
        !self.validation_errors.is_empty()
    }

    pub fn update_cursor(&mut self, cursor: usize, visible_width: usize) {
        self.cursor_position = cursor;
        if cursor < self.viewport_offset {
            self.viewport_offset = cursor;
        } else if cursor >= self.viewport_offset + visible_width {
            self.viewport_offset = cursor.saturating_sub(visible_width) + 1;
        }
    }

    pub fn cursor_to_end(&mut self) {
        let len = self.field_value(self.focused_field).chars().count();
        self.cursor_position = len;
        self.viewport_offset = 0;
    }

    pub fn from_profile(profile: &ConnectionProfile) -> Self {
        Self {
            name: profile.name.as_str().to_string(),
            host: profile.host.clone(),
            port: profile.port.to_string(),
            database: profile.database.clone(),
            user: profile.username.clone(),
            password: profile.password.clone(),
            ssl_mode: profile.ssl_mode,
            focused_field: ConnectionField::Name,
            ssl_dropdown: SslModeDropdown::default(),
            validation_errors: HashMap::new(),
            cursor_position: 0,
            viewport_offset: 0,
            is_first_run: false,
            editing_id: Some(profile.id.clone()),
        }
    }

    pub fn is_edit_mode(&self) -> bool {
        self.editing_id.is_some()
    }
}

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

    mod connection_field {
        use super::*;

        #[rstest]
        #[case(ConnectionField::Name, Some(ConnectionField::Host))]
        #[case(ConnectionField::Host, Some(ConnectionField::Port))]
        #[case(ConnectionField::Port, Some(ConnectionField::Database))]
        #[case(ConnectionField::Database, Some(ConnectionField::User))]
        #[case(ConnectionField::User, Some(ConnectionField::Password))]
        #[case(ConnectionField::Password, Some(ConnectionField::SslMode))]
        #[case(ConnectionField::SslMode, None)]
        fn next_returns_correct_field(
            #[case] field: ConnectionField,
            #[case] expected: Option<ConnectionField>,
        ) {
            assert_eq!(field.next(), expected);
        }

        #[rstest]
        #[case(ConnectionField::Name, None)]
        #[case(ConnectionField::Host, Some(ConnectionField::Name))]
        #[case(ConnectionField::Port, Some(ConnectionField::Host))]
        #[case(ConnectionField::Database, Some(ConnectionField::Port))]
        #[case(ConnectionField::User, Some(ConnectionField::Database))]
        #[case(ConnectionField::Password, Some(ConnectionField::User))]
        #[case(ConnectionField::SslMode, Some(ConnectionField::Password))]
        fn prev_returns_correct_field(
            #[case] field: ConnectionField,
            #[case] expected: Option<ConnectionField>,
        ) {
            assert_eq!(field.prev(), expected);
        }

        #[rstest]
        #[case(ConnectionField::Name, true)]
        #[case(ConnectionField::Host, true)]
        #[case(ConnectionField::Port, true)]
        #[case(ConnectionField::Database, true)]
        #[case(ConnectionField::User, true)]
        #[case(ConnectionField::Password, false)]
        #[case(ConnectionField::SslMode, false)]
        fn is_required_returns_correct_value(
            #[case] field: ConnectionField,
            #[case] expected: bool,
        ) {
            assert_eq!(field.is_required(), expected);
        }

        #[test]
        fn all_returns_fields_in_order() {
            let all = ConnectionField::all();
            assert_eq!(all.len(), 7);
            assert_eq!(all[0], ConnectionField::Name);
            assert_eq!(all[6], ConnectionField::SslMode);
        }
    }

    mod connection_setup_state {
        use super::*;

        #[test]
        fn default_has_correct_values() {
            let state = ConnectionSetupState::default();
            assert!(state.name.is_empty());
            assert_eq!(state.host, "localhost");
            assert_eq!(state.port, "5432");
            assert!(state.database.is_empty());
            assert!(state.user.is_empty());
            assert!(state.password.is_empty());
            assert_eq!(state.ssl_mode, SslMode::Prefer);
            assert_eq!(state.focused_field, ConnectionField::Name);
            assert!(state.is_first_run);
            assert!(state.editing_id.is_none());
        }

        #[test]
        fn default_name_without_database() {
            let state = ConnectionSetupState::default();
            assert_eq!(state.default_name(), "localhost");
        }

        #[test]
        fn default_name_with_database() {
            let state = ConnectionSetupState {
                database: "mydb".to_string(),
                ..Default::default()
            };
            assert_eq!(state.default_name(), "mydb@localhost");
        }

        #[test]
        fn has_errors_returns_false_when_empty() {
            let state = ConnectionSetupState::default();
            assert!(!state.has_errors());
        }

        #[test]
        fn has_errors_returns_true_when_errors_exist() {
            let state = ConnectionSetupState {
                validation_errors: HashMap::from([(ConnectionField::Host, "Required".to_string())]),
                ..Default::default()
            };
            assert!(state.has_errors());
        }

        #[test]
        fn clear_errors_removes_all_errors() {
            let mut state = ConnectionSetupState {
                validation_errors: HashMap::from([
                    (ConnectionField::Host, "Required".to_string()),
                    (ConnectionField::Port, "Invalid".to_string()),
                ]),
                ..Default::default()
            };
            state.clear_errors();
            assert!(!state.has_errors());
        }

        #[test]
        fn from_profile_populates_all_fields() {
            let profile = ConnectionProfile::new(
                "Test DB",
                "db.example.com",
                5433,
                "testdb",
                "testuser",
                "secret",
                SslMode::Require,
            )
            .unwrap();

            let state = ConnectionSetupState::from_profile(&profile);

            assert_eq!(state.name, "Test DB");
            assert_eq!(state.host, "db.example.com");
            assert_eq!(state.port, "5433");
            assert_eq!(state.database, "testdb");
            assert_eq!(state.user, "testuser");
            assert_eq!(state.password, "secret");
            assert_eq!(state.ssl_mode, SslMode::Require);
            assert_eq!(state.editing_id, Some(profile.id));
            assert!(!state.is_first_run);
        }

        #[test]
        fn is_edit_mode_returns_false_for_new() {
            let state = ConnectionSetupState::default();
            assert!(!state.is_edit_mode());
        }

        #[test]
        fn is_edit_mode_returns_true_for_edit() {
            let profile = ConnectionProfile::new(
                "Test",
                "localhost",
                5432,
                "db",
                "user",
                "",
                SslMode::Prefer,
            )
            .unwrap();
            let state = ConnectionSetupState::from_profile(&profile);
            assert!(state.is_edit_mode());
        }
    }
}