use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
use vcs_runner::Cmd;
pub(crate) mod alembic;
mod django;
pub(crate) mod ef_core;
pub(crate) mod laravel;
pub(crate) mod phoenix;
pub(crate) mod prisma;
mod python_venv;
pub(crate) 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 session_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());
postgres_url(&auth, &host, std::env::var("PGPORT").ok().as_deref(), name)
}
DbEngine::Mysql => {
let host = env_host("MYSQL_HOST", "127.0.0.1");
let port = std::env::var("MYSQL_TCP_PORT").ok().filter(|p| !p.is_empty());
let user = mysql_user();
let auth = auth_prefix(&user, std::env::var("MYSQL_PWD").ok());
format!("mysql://{auth}{}/{name}", url_authority(&host, Some(port.as_deref().unwrap_or("3306"))))
}
}
}
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() },
}
}
}
pub(crate) fn postgres_url(auth: &str, host: &str, port: Option<&str>, name: &str) -> String {
format!("postgresql://{auth}{}/{name}", url_authority(host, port))
}
pub(crate) fn url_authority(host: &str, port: Option<&str>) -> String {
let host = if host.contains(':') && !host.starts_with('[') { format!("[{host}]") } else { host.to_string() };
match port.filter(|p| !p.is_empty()) {
Some(p) => format!("{host}:{p}"),
None => host,
}
}
pub(crate) fn run_step(cmd: Cmd, what: &str) -> bool {
match cmd.run() {
Ok(_) => true,
Err(e) => {
eprintln!("Warning: {what} failed: {e}");
false
}
}
}
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 }
}
pub(crate) fn auth_prefix(user: &str, password: Option<String>) -> String {
if user.is_empty() {
return String::new();
}
let user = percent_encode_userinfo(user);
match password {
Some(p) if !p.is_empty() => format!("{user}:{}@", percent_encode_userinfo(&p)),
_ => format!("{user}@"),
}
}
pub(crate) fn percent_encode_userinfo(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => out.push(b as char),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
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 run_step_reports_whether_the_step_succeeded() {
assert!(run_step(Cmd::new("true"), "a passing step"));
assert!(!run_step(Cmd::new("false"), "a failing step"));
assert!(!run_step(Cmd::new("workon-no-such-binary"), "a missing tool"));
}
#[test]
fn postgres_url_carries_pgport_and_brackets_an_ipv6_host() {
assert_eq!(postgres_url("u@", "localhost", Some("5433"), "db"), "postgresql://u@localhost:5433/db");
assert_eq!(postgres_url("u@", "localhost", None, "db"), "postgresql://u@localhost/db");
assert_eq!(postgres_url("", "db.local", Some(""), "db"), "postgresql://db.local/db");
assert_eq!(postgres_url("u@", "::1", Some("5432"), "db"), "postgresql://u@[::1]:5432/db");
assert_eq!(url_authority("[::1]", None), "[::1]");
assert_eq!(url_authority("fe80::1", Some("3306")), "[fe80::1]:3306");
}
#[test]
fn userinfo_is_percent_encoded_in_urls() {
assert_eq!(percent_encode_userinfo("p@ss:w0/d#x"), "p%40ss%3Aw0%2Fd%23x");
assert_eq!(percent_encode_userinfo("plain-user_1.2~3"), "plain-user_1.2~3");
assert_eq!(auth_prefix("us er", Some("p@w:d".to_string())), "us%20er:p%40w%3Ad@");
assert_eq!(auth_prefix("bob", None), "bob@");
assert_eq!(auth_prefix("", Some("x".to_string())), "");
}
#[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");
}
}