use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DatabaseEngine {
MySQL,
Postgres,
SQLite,
}
impl DatabaseEngine {
pub fn as_str(&self) -> &'static str {
match self {
DatabaseEngine::MySQL => "mysql",
DatabaseEngine::Postgres => "postgres",
DatabaseEngine::SQLite => "sqlite",
}
}
}
impl std::fmt::Display for DatabaseEngine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DatabaseEngine::MySQL => write!(f, "MySQL"),
DatabaseEngine::Postgres => write!(f, "PostgreSQL"),
DatabaseEngine::SQLite => write!(f, "SQLite"),
}
}
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum SslMode {
Disabled,
#[default]
Preferred,
Required,
}
impl SslMode {
#[allow(dead_code)] pub fn from_str(value: &str) -> Self {
match value.to_lowercase().as_str() {
"disabled" | "disable" | "false" | "0" => Self::Disabled,
"required" | "require" | "true" | "1" => Self::Required,
_ => Self::Preferred,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "engine", rename_all = "lowercase")]
pub enum ConnectionConfig {
MySQL(MySqlConnectionConfig),
Postgres(PostgresConnectionConfig),
SQLite(SqliteConnectionConfig),
}
impl ConnectionConfig {
pub fn name(&self) -> &str {
match self {
ConnectionConfig::MySQL(c) => &c.name,
ConnectionConfig::Postgres(c) => &c.name,
ConnectionConfig::SQLite(c) => &c.name,
}
}
pub fn engine(&self) -> DatabaseEngine {
match self {
ConnectionConfig::MySQL(_) => DatabaseEngine::MySQL,
ConnectionConfig::Postgres(_) => DatabaseEngine::Postgres,
ConnectionConfig::SQLite(_) => DatabaseEngine::SQLite,
}
}
pub fn max_connections(&self) -> u32 {
match self {
ConnectionConfig::MySQL(c) => c.max_connections,
ConnectionConfig::Postgres(c) => c.max_connections,
ConnectionConfig::SQLite(c) => c.max_connections,
}
}
pub fn min_connections(&self) -> u32 {
match self {
ConnectionConfig::MySQL(c) => c.min_connections,
ConnectionConfig::Postgres(c) => c.min_connections,
ConnectionConfig::SQLite(_) => 1,
}
}
pub fn connect_timeout_secs(&self) -> u64 {
match self {
ConnectionConfig::MySQL(c) => c.connect_timeout_secs,
ConnectionConfig::Postgres(c) => c.connect_timeout_secs,
ConnectionConfig::SQLite(_) => 30,
}
}
pub fn connection_url(&self) -> String {
match self {
ConnectionConfig::MySQL(c) => c.connection_url(),
ConnectionConfig::Postgres(c) => c.connection_url(),
ConnectionConfig::SQLite(c) => c.connection_url(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MySqlConnectionConfig {
pub name: String,
#[serde(default = "default_host")]
pub host: String,
#[serde(default = "default_mysql_port")]
pub port: u16,
pub username: String,
#[serde(default)]
pub password: String,
#[serde(default)]
pub database: Option<String>,
#[serde(default)]
pub ssl_mode: SslMode,
#[serde(default = "default_max_connections")]
pub max_connections: u32,
#[serde(default = "default_min_connections")]
pub min_connections: u32,
#[serde(default = "default_timeout")]
pub connect_timeout_secs: u64,
}
impl MySqlConnectionConfig {
pub fn connection_url(&self) -> String {
let ssl_param = match self.ssl_mode {
SslMode::Disabled => "ssl-mode=DISABLED",
SslMode::Preferred => "ssl-mode=PREFERRED",
SslMode::Required => "ssl-mode=REQUIRED",
};
let db_part = self.database.as_deref().unwrap_or("");
format!(
"mysql://{}:{}@{}:{}/{}?{}",
self.username, self.password, self.host, self.port, db_part, ssl_param
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PostgresConnectionConfig {
pub name: String,
#[serde(default = "default_host")]
pub host: String,
#[serde(default = "default_postgres_port")]
pub port: u16,
pub username: String,
#[serde(default)]
pub password: String,
#[serde(default)]
pub database: Option<String>,
#[serde(default)]
pub ssl_mode: SslMode,
#[serde(default = "default_max_connections")]
pub max_connections: u32,
#[serde(default = "default_min_connections")]
pub min_connections: u32,
#[serde(default = "default_timeout")]
pub connect_timeout_secs: u64,
}
impl PostgresConnectionConfig {
pub fn connection_url(&self) -> String {
let ssl_param = match self.ssl_mode {
SslMode::Disabled => "sslmode=disable",
SslMode::Preferred => "sslmode=prefer",
SslMode::Required => "sslmode=require",
};
let db_part = self.database.as_deref().unwrap_or("postgres");
format!(
"postgres://{}:{}@{}:{}/{}?{}",
self.username, self.password, self.host, self.port, db_part, ssl_param
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SqliteConnectionConfig {
pub name: String,
pub path: String,
#[serde(default = "default_sqlite_max_connections")]
pub max_connections: u32,
}
impl SqliteConnectionConfig {
pub fn connection_url(&self) -> String {
if self.path == ":memory:" {
"sqlite::memory:".to_string()
} else {
format!("sqlite:{}", self.path)
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabasesConfig {
#[serde(default = "default_version")]
pub version: String,
pub databases: Vec<ConnectionConfig>,
#[serde(default)]
pub default_connection: Option<String>,
}
impl DatabasesConfig {
pub fn load() -> Result<Self, ConfigError> {
let paths = Self::config_paths();
for path in &paths {
if path.exists() {
let content = fs::read_to_string(path)
.map_err(|e| ConfigError::FileRead(path.clone(), e.to_string()))?;
let config: Self = serde_json::from_str(&content)
.map_err(|e| ConfigError::Parse(path.clone(), e.to_string()))?;
config.validate()?;
return Ok(config);
}
}
Err(ConfigError::NotFound)
}
pub fn config_paths() -> Vec<PathBuf> {
let mut paths = vec![PathBuf::from(".databases.json")];
if let Some(home) = dirs::home_dir() {
paths.push(home.join(".config/sqlx-mcp/.databases.json"));
}
paths
}
fn validate(&self) -> Result<(), ConfigError> {
if self.databases.is_empty() {
return Err(ConfigError::Validation(
"At least one database connection is required".to_string(),
));
}
let mut names = std::collections::HashSet::new();
for conn in &self.databases {
let name = conn.name();
if !names.insert(name) {
return Err(ConfigError::Validation(format!(
"Duplicate connection name: {}",
name
)));
}
}
if let Some(ref default) = self.default_connection {
if !names.contains(default.as_str()) {
return Err(ConfigError::Validation(format!(
"Default connection '{}' not found in databases",
default
)));
}
}
Ok(())
}
#[allow(dead_code)] pub fn get_connection(&self, name: &str) -> Option<&ConnectionConfig> {
self.databases.iter().find(|c| c.name() == name)
}
#[allow(dead_code)] pub fn get_default_connection(&self) -> Option<&ConnectionConfig> {
self.default_connection
.as_ref()
.and_then(|name| self.get_connection(name))
.or_else(|| self.databases.first())
}
pub fn save(&self, path: &PathBuf) -> Result<(), ConfigError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| ConfigError::FileWrite(path.clone(), e.to_string()))?;
}
let content = serde_json::to_string_pretty(self)
.map_err(|e| ConfigError::Serialize(e.to_string()))?;
fs::write(path, content)
.map_err(|e| ConfigError::FileWrite(path.clone(), e.to_string()))?;
Ok(())
}
}
impl Default for DatabasesConfig {
fn default() -> Self {
Self {
version: default_version(),
databases: Vec::new(),
default_connection: None,
}
}
}
fn default_version() -> String {
"1.0".to_string()
}
fn default_host() -> String {
"localhost".to_string()
}
fn default_mysql_port() -> u16 {
3306
}
fn default_postgres_port() -> u16 {
5432
}
fn default_max_connections() -> u32 {
5
}
fn default_min_connections() -> u32 {
1
}
fn default_timeout() -> u64 {
30
}
fn default_sqlite_max_connections() -> u32 {
1
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("Configuration file not found. Run 'sqlx-mcp --init' to create one.")]
NotFound,
#[error("Failed to read config file {0}: {1}")]
FileRead(PathBuf, String),
#[error("Failed to parse config file {0}: {1}")]
Parse(PathBuf, String),
#[error("Failed to write config file {0}: {1}")]
FileWrite(PathBuf, String),
#[error("Failed to serialize config: {0}")]
Serialize(String),
#[error("Configuration validation error: {0}")]
Validation(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mysql_connection_url() {
let config = MySqlConnectionConfig {
name: "test".to_string(),
host: "localhost".to_string(),
port: 3306,
username: "root".to_string(),
password: "secret".to_string(),
database: Some("mydb".to_string()),
ssl_mode: SslMode::Preferred,
max_connections: 5,
min_connections: 1,
connect_timeout_secs: 30,
};
let url = config.connection_url();
assert!(url.starts_with("mysql://root:secret@localhost:3306/mydb"));
assert!(url.contains("ssl-mode=PREFERRED"));
}
#[test]
fn test_postgres_connection_url() {
let config = PostgresConnectionConfig {
name: "test".to_string(),
host: "localhost".to_string(),
port: 5432,
username: "postgres".to_string(),
password: "secret".to_string(),
database: Some("mydb".to_string()),
ssl_mode: SslMode::Required,
max_connections: 5,
min_connections: 1,
connect_timeout_secs: 30,
};
let url = config.connection_url();
assert!(url.starts_with("postgres://postgres:secret@localhost:5432/mydb"));
assert!(url.contains("sslmode=require"));
}
#[test]
fn test_sqlite_connection_url() {
let config = SqliteConnectionConfig {
name: "test".to_string(),
path: "/data/test.db".to_string(),
max_connections: 1,
};
assert_eq!(config.connection_url(), "sqlite:/data/test.db");
let memory_config = SqliteConnectionConfig {
name: "memory".to_string(),
path: ":memory:".to_string(),
max_connections: 1,
};
assert_eq!(memory_config.connection_url(), "sqlite::memory:");
}
#[test]
fn test_ssl_mode_parsing() {
assert_eq!(SslMode::from_str("disabled"), SslMode::Disabled);
assert_eq!(SslMode::from_str("DISABLED"), SslMode::Disabled);
assert_eq!(SslMode::from_str("false"), SslMode::Disabled);
assert_eq!(SslMode::from_str("required"), SslMode::Required);
assert_eq!(SslMode::from_str("true"), SslMode::Required);
assert_eq!(SslMode::from_str("preferred"), SslMode::Preferred);
assert_eq!(SslMode::from_str("unknown"), SslMode::Preferred);
}
#[test]
fn test_config_validation_duplicate_names() {
let config = DatabasesConfig {
version: "1.0".to_string(),
databases: vec![
ConnectionConfig::SQLite(SqliteConnectionConfig {
name: "test".to_string(),
path: "/data/a.db".to_string(),
max_connections: 1,
}),
ConnectionConfig::SQLite(SqliteConnectionConfig {
name: "test".to_string(),
path: "/data/b.db".to_string(),
max_connections: 1,
}),
],
default_connection: None,
};
assert!(config.validate().is_err());
}
}