tsuzuri-libsql 0.1.0

LibSQL integration for Tsuzuri
Documentation
use crate::read::{ConnectionConfig, EmbeddedReplicaConfig, RemoteConfig};
use std::time::Duration;

#[derive(Debug, Clone)]
pub struct LibSqlConfig {
    pub connection: ConnectionConfig,
}

impl LibSqlConfig {
    pub fn builder() -> LibSqlConfigBuilder {
        LibSqlConfigBuilder::new()
    }

    pub fn from_remote(url: impl Into<String>, auth_token: impl Into<String>) -> Self {
        Self {
            connection: ConnectionConfig::Remote(RemoteConfig {
                url: url.into(),
                auth_token: auth_token.into(),
            }),
        }
    }

    pub fn from_embedded_replica(
        local_path: impl Into<String>,
        sync_url: impl Into<String>,
        auth_token: impl Into<String>,
    ) -> Self {
        Self {
            connection: ConnectionConfig::EmbeddedReplica(EmbeddedReplicaConfig {
                local_path: local_path.into(),
                sync_url: sync_url.into(),
                auth_token: auth_token.into(),
                sync_interval: None,
                encryption_key: None,
            }),
        }
    }

    pub fn from_env() -> Result<Self, Box<dyn std::error::Error>> {
        use std::env;

        if env::var("DATABASE_USE_EMBEDDED_REPLICA").unwrap_or_default() == "true" {
            let config = EmbeddedReplicaConfig {
                local_path: env::var("DATABASE_LOCAL_PATH").unwrap_or_else(|_| "local.db".to_string()),
                sync_url: env::var("DATABASE_URL")?,
                auth_token: env::var("DATABASE_TOKEN")?,
                sync_interval: env::var("DATABASE_SYNC_INTERVAL_SECS")
                    .ok()
                    .and_then(|s| s.parse::<u64>().ok())
                    .map(Duration::from_secs),
                encryption_key: env::var("DATABASE_ENCRYPTION_KEY").ok(),
            };
            Ok(Self {
                connection: ConnectionConfig::EmbeddedReplica(config),
            })
        } else {
            let config = RemoteConfig {
                url: env::var("DATABASE_URL")?,
                auth_token: env::var("DATABASE_TOKEN")?,
            };
            Ok(Self {
                connection: ConnectionConfig::Remote(config),
            })
        }
    }

    pub fn validate(&self) -> Result<(), ConfigError> {
        match &self.connection {
            ConnectionConfig::Remote(config) => {
                if config.url.is_empty() {
                    return Err(ConfigError::InvalidConfiguration("URL cannot be empty".to_string()));
                }
                if config.auth_token.is_empty() {
                    return Err(ConfigError::InvalidConfiguration("Auth token cannot be empty".to_string()));
                }
                if !config.url.starts_with("libsql://") && !config.url.starts_with("https://") {
                    return Err(ConfigError::InvalidConfiguration(
                        "URL must start with libsql:// or https://".to_string(),
                    ));
                }
            }
            ConnectionConfig::EmbeddedReplica(config) => {
                if config.local_path.is_empty() {
                    return Err(ConfigError::InvalidConfiguration("Local path cannot be empty".to_string()));
                }
                if config.sync_url.is_empty() {
                    return Err(ConfigError::InvalidConfiguration("Sync URL cannot be empty".to_string()));
                }
                if config.auth_token.is_empty() {
                    return Err(ConfigError::InvalidConfiguration("Auth token cannot be empty".to_string()));
                }
                if !config.sync_url.starts_with("libsql://") && !config.sync_url.starts_with("https://") {
                    return Err(ConfigError::InvalidConfiguration(
                        "Sync URL must start with libsql:// or https://".to_string(),
                    ));
                }
                if let Some(ref key) = config.encryption_key {
                    let key_len = if key.len() == 64 {
                        32
                    } else {
                        key.len()
                    };
                    if key_len != 32 {
                        return Err(ConfigError::InvalidConfiguration(
                            "Encryption key must be exactly 32 bytes (256 bits)".to_string(),
                        ));
                    }
                }
            }
        }
        Ok(())
    }
}

impl Default for LibSqlConfig {
    fn default() -> Self {
        Self {
            connection: ConnectionConfig::Remote(RemoteConfig {
                url: String::new(),
                auth_token: String::new(),
            }),
        }
    }
}

#[derive(Debug, Default)]
pub struct LibSqlConfigBuilder {
    connection_type: Option<ConnectionType>,
    url: Option<String>,
    auth_token: Option<String>,
    local_path: Option<String>,
    sync_interval: Option<Duration>,
    encryption_key: Option<String>,
}

#[derive(Debug)]
enum ConnectionType {
    Remote,
    EmbeddedReplica,
}

impl LibSqlConfigBuilder {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn remote(mut self) -> Self {
        self.connection_type = Some(ConnectionType::Remote);
        self
    }

    pub fn embedded_replica(mut self) -> Self {
        self.connection_type = Some(ConnectionType::EmbeddedReplica);
        self
    }

    pub fn url(mut self, url: impl Into<String>) -> Self {
        self.url = Some(url.into());
        self
    }

    pub fn auth_token(mut self, token: impl Into<String>) -> Self {
        self.auth_token = Some(token.into());
        self
    }

    pub fn local_path(mut self, path: impl Into<String>) -> Self {
        self.local_path = Some(path.into());
        self
    }

    pub fn sync_interval(mut self, interval: Duration) -> Self {
        self.sync_interval = Some(interval);
        self
    }

    pub fn encryption_key(mut self, key: impl Into<String>) -> Self {
        self.encryption_key = Some(key.into());
        self
    }

    pub fn build(self) -> Result<LibSqlConfig, ConfigError> {
        let connection_type = self.connection_type.ok_or(ConfigError::MissingConnectionType)?;
        let url = self.url.ok_or(ConfigError::MissingUrl)?;
        let auth_token = self.auth_token.ok_or(ConfigError::MissingAuthToken)?;

        let connection = match connection_type {
            ConnectionType::Remote => ConnectionConfig::Remote(RemoteConfig { url, auth_token }),
            ConnectionType::EmbeddedReplica => {
                let local_path = self.local_path.ok_or(ConfigError::MissingLocalPath)?;
                ConnectionConfig::EmbeddedReplica(EmbeddedReplicaConfig {
                    local_path,
                    sync_url: url,
                    auth_token,
                    sync_interval: self.sync_interval,
                    encryption_key: self.encryption_key,
                })
            }
        };

        let config = LibSqlConfig { connection };
        config.validate()?;
        Ok(config)
    }
}

#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    #[error("Connection type not specified. Use .remote() or .embedded_replica()")]
    MissingConnectionType,
    #[error("URL is required")]
    MissingUrl,
    #[error("Authentication token is required")]
    MissingAuthToken,
    #[error("Local path is required for embedded replica")]
    MissingLocalPath,
    #[error("Invalid configuration: {0}")]
    InvalidConfiguration(String),
}