use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::{error::Error, fmt}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DatabaseKind { Postgres, Mysql, Sqlite, } impl DatabaseKind { pub fn as_str(self) -> &'static str { match self { Self::Postgres => "postgres", Self::Mysql => "mysql", Self::Sqlite => "sqlite", } } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ConnectionTransport { Tcp { host: String, port: u16 }, File { path: PathBuf }, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ConnectionTarget { pub kind: DatabaseKind, pub transport: ConnectionTransport, pub database: Option, pub username: Option, #[serde(skip, default)] pub password: Option, } impl ConnectionTarget { pub fn new( kind: DatabaseKind, transport: ConnectionTransport, database: Option, username: Option, ) -> Result { let target = Self { kind, transport, database, username, password: None, }; target.validate()?; Ok(target) } pub fn validate(&self) -> Result<(), CoreError> { match (&self.kind, &self.transport) { (DatabaseKind::Sqlite, ConnectionTransport::File { path }) => { if path.as_os_str().is_empty() { return Err(CoreError::InvalidConnection("sqlite path is empty")); } } (DatabaseKind::Sqlite, ConnectionTransport::Tcp { .. }) => { return Err(CoreError::InvalidConnection( "sqlite must use a filesystem path", )); } (_, ConnectionTransport::File { .. }) => { return Err(CoreError::InvalidConnection( "server databases must use host and port", )); } (_, ConnectionTransport::Tcp { host, port }) => { if host.trim().is_empty() { return Err(CoreError::InvalidConnection("host is empty")); } if *port == 0 { return Err(CoreError::InvalidConnection("port must be non-zero")); } } } if self.kind != DatabaseKind::Sqlite && self .database .as_deref() .is_none_or(|database| database.trim().is_empty()) { return Err(CoreError::InvalidConnection( "server databases require a database name", )); } Ok(()) } pub fn redacted_endpoint(&self) -> String { match &self.transport { ConnectionTransport::Tcp { host, port } => { let database = self.database.as_deref().unwrap_or(""); match self.username.as_deref() { Some(username) => { format!( "{}://{}@{}:{}/{}", self.kind.as_str(), username, host, port, database ) } None => format!("{}://{}:{}/{}", self.kind.as_str(), host, port, database), } } ConnectionTransport::File { path } => { format!("sqlite://{}", path.display()) } } } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct InspectRequest { pub schema: Option, pub table: Option, } impl InspectRequest { pub fn new(schema: Option, table: Option) -> Result { let request = Self { schema, table }; request.validate()?; Ok(request) } pub fn validate(&self) -> Result<(), CoreError> { if self.table.is_some() && self.schema.is_none() { return Err(CoreError::InvalidInspect( "table inspection requires --schema", )); } Ok(()) } } #[derive(Clone, Debug, PartialEq, Eq)] pub enum SchemaAvailability { Ready, Restricted, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct SchemaSummary { pub name: String, pub availability: SchemaAvailability, pub note: Option, } impl SchemaSummary { pub fn ready(name: impl Into) -> Self { Self { name: name.into(), availability: SchemaAvailability::Ready, note: None, } } pub fn restricted(name: impl Into, note: impl Into) -> Self { Self { name: name.into(), availability: SchemaAvailability::Restricted, note: Some(note.into()), } } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct TableSummary { pub schema: String, pub name: String, pub kind: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ColumnSummary { pub name: String, pub data_type: String, pub nullable: bool, pub primary_key: bool, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum InspectResult { Schemas(Vec), Tables(Vec), Columns(Vec), } #[derive(Clone, Debug, PartialEq, Eq)] pub enum QuerySource { Inline, File(PathBuf), } impl QuerySource { pub fn label(&self) -> &'static str { match self { Self::Inline => "inline SQL", Self::File(_) => "SQL file", } } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct QueryRequest { pub sql: String, pub source: QuerySource, pub parameters: Vec, } impl QueryRequest { pub fn new( sql: String, source: QuerySource, parameters: Vec, ) -> Result { let request = Self { sql, source, parameters, }; request.validate()?; Ok(request) } pub fn validate(&self) -> Result<(), CoreError> { if self.sql.trim().is_empty() { return Err(CoreError::EmptyQuery); } Ok(()) } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct QueryResult { pub columns: Vec, pub rows: Vec>, pub rows_affected: Option, } impl QueryResult { pub fn rows(columns: Vec, rows: Vec>) -> Self { Self { columns, rows, rows_affected: None, } } pub fn command(rows_affected: u64) -> Self { Self { columns: Vec::new(), rows: Vec::new(), rows_affected: Some(rows_affected), } } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ExportFormat { Csv, Json, } impl ExportFormat { pub fn file_extension(self) -> &'static str { match self { Self::Csv => "csv", Self::Json => "json", } } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ExportRequest { pub format: ExportFormat, pub output_path: PathBuf, pub overwrite: bool, } impl ExportRequest { pub fn new( format: ExportFormat, output_path: PathBuf, overwrite: bool, ) -> Result { let request = Self { format, output_path, overwrite, }; request.validate()?; Ok(request) } pub fn validate(&self) -> Result<(), CoreError> { if self.output_path.file_name().is_none() { return Err(CoreError::InvalidExport( "output path must include a filename", )); } Ok(()) } } #[derive(Debug, PartialEq, Eq)] pub enum CoreError { InvalidConnection(&'static str), InvalidInspect(&'static str), InvalidQuery(&'static str), EmptyQuery, InvalidExport(&'static str), } impl fmt::Display for CoreError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidConnection(message) => { write!(formatter, "connection target is invalid: {message}") } Self::InvalidInspect(message) => { write!(formatter, "inspect request is invalid: {message}") } Self::InvalidQuery(message) => { write!(formatter, "query request is invalid: {message}") } Self::EmptyQuery => write!(formatter, "query text is empty"), Self::InvalidExport(message) => { write!(formatter, "export request is invalid: {message}") } } } } impl Error for CoreError {} #[cfg(test)] mod tests { use super::*; #[test] fn rejects_server_target_without_database() { let result = ConnectionTarget::new( DatabaseKind::Postgres, ConnectionTransport::Tcp { host: String::from("127.0.0.1"), port: 5432, }, None, Some(String::from("dbtool")), ); assert_eq!( result, Err(CoreError::InvalidConnection( "server databases require a database name" )) ); } #[test] fn redacts_target_without_password() { let target = ConnectionTarget::new( DatabaseKind::Mysql, ConnectionTransport::Tcp { host: String::from("db.internal"), port: 3306, }, Some(String::from("qa_demo")), Some(String::from("dbtool")), ) .expect("target should be valid"); assert_eq!( target.redacted_endpoint(), "mysql://dbtool@db.internal:3306/qa_demo" ); } #[test] fn rejects_empty_query() { let result = QueryRequest::new(String::from(" "), QuerySource::Inline, Vec::new()); assert_eq!(result, Err(CoreError::EmptyQuery)); } #[test] fn rejects_table_without_schema() { let result = InspectRequest::new(None, Some(String::from("accounts"))); assert_eq!( result, Err(CoreError::InvalidInspect( "table inspection requires --schema" )) ); } #[test] fn connection_target_serialization_omits_password() { let mut target = ConnectionTarget::new( DatabaseKind::Postgres, ConnectionTransport::Tcp { host: String::from("127.0.0.1"), port: 5432, }, Some(String::from("qa_demo")), Some(String::from("dbtool")), ) .expect("target should be valid"); target.password = Some(String::from("secret")); let json = serde_json::to_string(&target).expect("target should serialize"); assert!(!json.contains("secret")); assert!(!json.contains("password")); let restored: ConnectionTarget = serde_json::from_str(&json).expect("target should deserialize"); assert_eq!(restored.password, None); assert_eq!(restored.kind, DatabaseKind::Postgres); } }