use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Engine {
Postgres,
MySql,
Sqlite,
}
impl Engine {
pub const ALL: [Engine; 3] = [Engine::Postgres, Engine::MySql, Engine::Sqlite];
pub fn label(self) -> &'static str {
match self {
Engine::Postgres => "PostgreSQL",
Engine::MySql => "MySQL",
Engine::Sqlite => "SQLite",
}
}
pub fn default_port(self) -> u16 {
match self {
Engine::Postgres => 5432,
Engine::MySql => 3306,
Engine::Sqlite => 0,
}
}
pub fn is_file_based(self) -> bool {
matches!(self, Engine::Sqlite)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum SafetyMode {
ReadOnly,
ConfirmWrites,
#[default]
Staged,
AutoApply,
}
impl SafetyMode {
pub const ALL: [SafetyMode; 4] = [
SafetyMode::ReadOnly,
SafetyMode::ConfirmWrites,
SafetyMode::Staged,
SafetyMode::AutoApply,
];
pub fn label(self) -> &'static str {
match self {
SafetyMode::ReadOnly => "Read-only",
SafetyMode::ConfirmWrites => "Confirm writes",
SafetyMode::Staged => "Staged edits",
SafetyMode::AutoApply => "Auto-apply",
}
}
pub fn description(self) -> &'static str {
match self {
SafetyMode::ReadOnly => "Refuses anything that writes",
SafetyMode::ConfirmWrites => "Shows every write before it runs",
SafetyMode::Staged => "Edits wait until you apply them",
SafetyMode::AutoApply => "Edits are written when you leave the row",
}
}
pub fn is_read_only(self) -> bool {
matches!(self, SafetyMode::ReadOnly)
}
pub fn confirms_writes(self) -> bool {
matches!(self, SafetyMode::ConfirmWrites)
}
pub fn auto_applies(self) -> bool {
matches!(self, SafetyMode::AutoApply)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TagColor {
Red,
Orange,
Yellow,
Green,
Teal,
Blue,
Purple,
Pink,
Gray,
}
impl TagColor {
pub const ALL: [TagColor; 9] = [
TagColor::Red,
TagColor::Orange,
TagColor::Yellow,
TagColor::Green,
TagColor::Teal,
TagColor::Blue,
TagColor::Purple,
TagColor::Pink,
TagColor::Gray,
];
pub fn label(self) -> &'static str {
match self {
TagColor::Red => "Red",
TagColor::Orange => "Orange",
TagColor::Yellow => "Yellow",
TagColor::Green => "Green",
TagColor::Teal => "Teal",
TagColor::Blue => "Blue",
TagColor::Purple => "Purple",
TagColor::Pink => "Pink",
TagColor::Gray => "Gray",
}
}
pub fn key(self) -> &'static str {
match self {
TagColor::Red => "red",
TagColor::Orange => "orange",
TagColor::Yellow => "yellow",
TagColor::Green => "green",
TagColor::Teal => "teal",
TagColor::Blue => "blue",
TagColor::Purple => "purple",
TagColor::Pink => "pink",
TagColor::Gray => "gray",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionConfig {
pub id: Uuid,
pub name: String,
pub engine: Engine,
pub host: String,
pub port: u16,
pub username: String,
pub database: String,
#[serde(default)]
pub safety: SafetyMode,
#[serde(default)]
pub color: Option<TagColor>,
#[serde(default)]
pub last_connected: Option<DateTime<Utc>>,
}
impl ConnectionConfig {
pub fn new(engine: Engine) -> Self {
Self {
id: Uuid::new_v4(),
name: String::new(),
engine,
host: "localhost".into(),
port: engine.default_port(),
username: String::new(),
database: String::new(),
safety: SafetyMode::default(),
color: None,
last_connected: None,
}
}
pub fn display_name(&self) -> String {
if !self.name.trim().is_empty() {
return self.name.clone();
}
if self.engine.is_file_based() {
return file_name(&self.database);
}
self.display_target()
}
pub fn display_target(&self) -> String {
if self.engine.is_file_based() {
return self.database.clone();
}
format!("{}:{}/{}", self.host, self.port, self.database)
}
pub fn is_risky_auto_apply(&self) -> bool {
is_risky_auto_apply(&self.name, self.color, self.safety)
}
}
pub fn is_risky_auto_apply(name: &str, color: Option<TagColor>, safety: SafetyMode) -> bool {
safety.auto_applies() && (color == Some(TagColor::Red) || name.to_lowercase().contains("prod"))
}
impl Default for ConnectionConfig {
fn default() -> Self {
Self::new(Engine::Postgres)
}
}
pub(crate) fn file_name(path: &str) -> String {
std::path::Path::new(path)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_production_connection_with_auto_apply_is_risky() {
assert!(is_risky_auto_apply(
"App",
Some(TagColor::Red),
SafetyMode::AutoApply
));
assert!(is_risky_auto_apply(
"prod-east",
None,
SafetyMode::AutoApply
));
}
#[test]
fn a_production_connection_is_not_risky_under_a_safer_mode() {
for safety in [
SafetyMode::ReadOnly,
SafetyMode::ConfirmWrites,
SafetyMode::Staged,
] {
assert!(!is_risky_auto_apply(
"Production",
Some(TagColor::Red),
safety
));
}
}
#[test]
fn auto_apply_is_not_risky_without_a_production_marker() {
assert!(!is_risky_auto_apply("", None, SafetyMode::AutoApply));
assert!(!is_risky_auto_apply(
"Staging",
Some(TagColor::Orange),
SafetyMode::AutoApply
));
}
}