use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
use vcs_runner::Cmd;
mod alembic;
mod django;
mod ef_core;
mod laravel;
mod phoenix;
mod prisma;
mod python_venv;
mod rails;
pub trait Provisioner: Send + Sync {
fn name(&self) -> &'static str;
fn detect(&self, ws_dir: &Path) -> bool;
fn setup(&self, ctx: &ProvisionCtx<'_>) -> Result<Setup>;
}
pub struct ProvisionCtx<'a> {
pub project_dir: &'a Path,
pub project_name: &'a str,
pub ws_id: &'a str,
pub ws_dir: &'a Path,
pub mise_vars: &'a HashMap<String, String>,
}
#[derive(Default)]
pub struct Setup {
pub resources: Vec<Resource>,
pub env: Vec<(String, String)>,
pub env_file: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Resource {
PostgresDb { name: String },
MysqlDb { name: String },
}
impl Resource {
pub fn teardown(&self) {
match self {
Resource::PostgresDb { name } => {
let _ = Cmd::new("dropdb").arg(name).run();
eprintln!("Dropped test database {name}");
}
Resource::MysqlDb { name } => {
let _ = mysqladmin(&["--force", "drop", name]).run();
eprintln!("Dropped test database {name}");
}
}
}
pub fn db_name(&self) -> &str {
match self {
Resource::PostgresDb { name } | Resource::MysqlDb { name } => name,
}
}
}
pub enum DbEngine {
Postgres,
Mysql,
}
impl DbEngine {
pub fn create(&self, name: &str) -> Result<()> {
match self {
DbEngine::Postgres => {
if Cmd::new("createdb").arg(name).run().is_ok() {
Ok(())
} else {
bail!("createdb {name} failed (is the Postgres server running?)")
}
}
DbEngine::Mysql => {
if mysqladmin(&["create", name]).run().is_ok() {
Ok(())
} else {
bail!("mysqladmin create {name} failed (is the MySQL server running?)")
}
}
}
}
pub fn url(&self, name: &str) -> String {
match self {
DbEngine::Postgres => {
let host = env_host("PGHOST", "localhost");
let user = std::env::var("PGUSER").ok().or_else(|| std::env::var("USER").ok()).unwrap_or_default();
let auth = auth_prefix(&user, std::env::var("PGPASSWORD").ok());
format!("postgresql://{auth}{host}/{name}")
}
DbEngine::Mysql => {
let host = env_host("MYSQL_HOST", "127.0.0.1");
let port = std::env::var("MYSQL_TCP_PORT").unwrap_or_else(|_| "3306".into());
let user = mysql_user();
let auth = auth_prefix(&user, std::env::var("MYSQL_PWD").ok());
format!("mysql://{auth}{host}:{port}/{name}")
}
}
}
pub fn resource(&self, name: &str) -> Resource {
match self {
DbEngine::Postgres => Resource::PostgresDb { name: name.to_string() },
DbEngine::Mysql => Resource::MysqlDb { name: name.to_string() },
}
}
}
fn env_host(var: &str, default: &str) -> String {
let host = std::env::var(var).unwrap_or_default();
if host.is_empty() || host.starts_with('/') { default.to_string() } else { host }
}
fn auth_prefix(user: &str, password: Option<String>) -> String {
match (user.is_empty(), password) {
(false, Some(p)) if !p.is_empty() => format!("{user}:{p}@"),
(false, _) => format!("{user}@"),
(true, _) => String::new(),
}
}
fn mysql_user() -> String {
std::env::var("MYSQL_USER").ok().or_else(|| std::env::var("USER").ok()).unwrap_or_else(|| "root".into())
}
fn mysqladmin(args: &[&str]) -> Cmd {
Cmd::new("mysqladmin").arg("-u").arg(mysql_user()).args(args)
}
pub fn test_db_name(project_name: &str, ws_id: &str) -> String {
let sanitize = |s: &str| {
s.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' })
.collect::<String>()
};
let suffix = format!("_{}_test", sanitize(ws_id));
let proj = sanitize(project_name);
let max_proj = 63usize.saturating_sub(suffix.len());
let proj = if proj.len() > max_proj { &proj[..max_proj] } else { proj.as_str() };
format!("{proj}{suffix}")
}
pub fn venv_python(ws_dir: &Path) -> PathBuf {
let venv = ws_dir.join(".venv/bin/python");
if venv.is_file() {
venv
} else {
PathBuf::from("python3")
}
}
pub fn provisioners() -> Vec<Box<dyn Provisioner>> {
vec![
Box::new(python_venv::PythonVenv),
Box::new(rails::Rails),
Box::new(prisma::Prisma),
Box::new(alembic::Alembic),
Box::new(django::Django),
Box::new(laravel::Laravel),
Box::new(ef_core::EfCore),
Box::new(phoenix::Phoenix),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_db_name_matches_legacy_for_normal_names() {
assert_eq!(test_db_name("mbc", "ws-abc123"), "mbc_ws_abc123_test");
assert_eq!(test_db_name("my-app", "ws-abc123-fix-bug"), "my_app_ws_abc123_fix_bug_test");
}
#[test]
fn test_db_name_stays_within_63_bytes() {
let long = "a".repeat(200);
let name = test_db_name(&long, "ws-abc123");
assert!(name.len() <= 63, "len {} > 63: {name}", name.len());
assert!(name.ends_with("_ws_abc123_test"));
}
#[test]
fn resource_round_trips_through_json() {
let r = Resource::PostgresDb { name: "mbc_ws_abc_test".into() };
let json = serde_json::to_string(&r).unwrap();
assert_eq!(json, r#"{"type":"postgres_db","name":"mbc_ws_abc_test"}"#);
assert_eq!(serde_json::from_str::<Resource>(&json).unwrap(), r);
}
#[test]
fn postgres_create_and_drop_roundtrip() {
let name = "workon_provision_selftest_db";
Resource::PostgresDb { name: name.into() }.teardown();
if DbEngine::Postgres.create(name).is_err() {
return; }
Resource::PostgresDb { name: name.into() }.teardown();
let recreated = DbEngine::Postgres.create(name).is_ok();
Resource::PostgresDb { name: name.into() }.teardown(); assert!(recreated, "dropdb should have removed the DB so createdb succeeds again");
}
#[test]
fn mysql_create_and_drop_roundtrip() {
let name = "workon_provision_selftest_mysql";
Resource::MysqlDb { name: name.into() }.teardown();
if DbEngine::Mysql.create(name).is_err() {
return; }
Resource::MysqlDb { name: name.into() }.teardown();
let recreated = DbEngine::Mysql.create(name).is_ok();
Resource::MysqlDb { name: name.into() }.teardown();
assert!(recreated, "mysqladmin drop should have removed the DB so create succeeds again");
}
}