chore: bootstrap independent git workflow
Some checks failed
release-smoke / macos-13 / x86_64-apple-darwin (push) Has been cancelled
release-smoke / ubuntu-latest / x86_64-unknown-linux-gnu (push) Has been cancelled
release-smoke / windows-latest / x86_64-pc-windows-msvc (push) Has been cancelled

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Paperclip CTO
2026-03-26 03:49:06 +00:00
commit 7424491944
60 changed files with 7793 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
[package]
name = "db-config"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
publish = false
[dependencies]
db-core.workspace = true
[lints]
workspace = true

123
crates/db-config/src/lib.rs Normal file
View File

@@ -0,0 +1,123 @@
use db_core::{ConnectionTarget, CoreError};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConnectionProfile {
pub name: String,
pub target: ConnectionTarget,
pub password_env_var: Option<String>,
}
impl ConnectionProfile {
pub fn new(
name: impl Into<String>,
target: ConnectionTarget,
password_env_var: Option<String>,
) -> Result<Self, CoreError> {
let profile = Self {
name: name.into(),
target,
password_env_var,
};
profile.validate()?;
Ok(profile)
}
pub fn validate(&self) -> Result<(), CoreError> {
if self.name.trim().is_empty() {
return Err(CoreError::InvalidConnection("profile name is empty"));
}
self.target.validate()
}
pub fn redacted_summary(&self) -> String {
match &self.password_env_var {
Some(password_env_var) => format!(
"{} via password env {}",
self.target.redacted_endpoint(),
password_env_var
),
None => self.target.redacted_endpoint(),
}
}
pub fn with_password(mut self, password: Option<String>) -> Self {
self.target.password = password;
self
}
}
#[cfg(test)]
mod tests {
use db_core::{ConnectionTarget, ConnectionTransport, DatabaseKind};
use super::*;
#[test]
fn rejects_empty_profile_name() {
let target = ConnectionTarget::new(
DatabaseKind::Sqlite,
ConnectionTransport::File {
path: "demo.sqlite".into(),
},
None,
None,
)
.expect("target should be valid");
let result = ConnectionProfile::new(" ", target, None);
assert_eq!(
result,
Err(CoreError::InvalidConnection("profile name is empty"))
);
}
#[test]
fn summary_mentions_password_env_without_secret() {
let target = ConnectionTarget::new(
DatabaseKind::Postgres,
ConnectionTransport::Tcp {
host: String::from("127.0.0.1"),
port: 55432,
},
Some(String::from("dbtool_demo")),
Some(String::from("dbtool")),
)
.expect("target should be valid");
let profile =
ConnectionProfile::new("local", target, Some(String::from("DBTOOL_PASSWORD")))
.expect("profile should be valid");
assert_eq!(
profile.redacted_summary(),
"postgres://dbtool@127.0.0.1:55432/dbtool_demo via password env DBTOOL_PASSWORD"
);
}
#[test]
fn injects_password_without_changing_redacted_output() {
let target = ConnectionTarget::new(
DatabaseKind::Postgres,
ConnectionTransport::Tcp {
host: String::from("127.0.0.1"),
port: 55432,
},
Some(String::from("dbtool_demo")),
Some(String::from("dbtool")),
)
.expect("target should be valid");
let profile =
ConnectionProfile::new("local", target, Some(String::from("DBTOOL_PASSWORD")))
.expect("profile should be valid")
.with_password(Some(String::from("secret")));
assert_eq!(profile.target.password.as_deref(), Some("secret"));
assert_eq!(
profile.redacted_summary(),
"postgres://dbtool@127.0.0.1:55432/dbtool_demo via password env DBTOOL_PASSWORD"
);
}
}

View File

@@ -0,0 +1,9 @@
[package]
name = "db-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
publish = false
[lints]
workspace = true

384
crates/db-core/src/lib.rs Normal file
View File

@@ -0,0 +1,384 @@
use std::path::PathBuf;
use std::{error::Error, fmt};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
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)]
pub enum ConnectionTransport {
Tcp { host: String, port: u16 },
File { path: PathBuf },
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConnectionTarget {
pub kind: DatabaseKind,
pub transport: ConnectionTransport,
pub database: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
}
impl ConnectionTarget {
pub fn new(
kind: DatabaseKind,
transport: ConnectionTransport,
database: Option<String>,
username: Option<String>,
) -> Result<Self, CoreError> {
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("<none>");
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<String>,
pub table: Option<String>,
}
impl InspectRequest {
pub fn new(schema: Option<String>, table: Option<String>) -> Result<Self, CoreError> {
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 struct SchemaSummary {
pub name: String,
}
#[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<SchemaSummary>),
Tables(Vec<TableSummary>),
Columns(Vec<ColumnSummary>),
}
#[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<String>,
}
impl QueryRequest {
pub fn new(
sql: String,
source: QuerySource,
parameters: Vec<String>,
) -> Result<Self, CoreError> {
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<String>,
pub rows: Vec<Vec<String>>,
pub rows_affected: Option<u64>,
}
impl QueryResult {
pub fn rows(columns: Vec<String>, rows: Vec<Vec<String>>) -> 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<Self, CoreError> {
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"
))
);
}
}

View File

@@ -0,0 +1,15 @@
[package]
name = "db-drivers"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
publish = false
[dependencies]
db-core.workspace = true
mysql.workspace = true
postgres.workspace = true
rusqlite.workspace = true
[lints]
workspace = true

1110
crates/db-drivers/src/lib.rs Normal file

File diff suppressed because it is too large Load Diff