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 {
self.url_in(name, &|var| std::env::var(var).ok())
}
fn url_in(&self, name: &str, env: &dyn Fn(&str) -> Option<String>) -> String {
match self {
DbEngine::Postgres => {
let host = env_host(env("PGHOST"), "localhost");
let user = env("PGUSER").or_else(|| env("USER")).unwrap_or_default();
let auth = auth_prefix(&user, env("PGPASSWORD"));
postgres_url(&auth, &host, env("PGPORT").as_deref(), name)
}
DbEngine::Mysql => {
let host = env_host(env("MYSQL_HOST"), "127.0.0.1");
let port = env("MYSQL_TCP_PORT").filter(|p| !p.is_empty());
let user = mysql_user(env);
let auth = auth_prefix(&user, env("MYSQL_PWD"));
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(value: Option<String>, default: &str) -> String {
let host = value.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(env: &dyn Fn(&str) -> Option<String>) -> String {
env("MYSQL_USER").or_else(|| env("USER")).unwrap_or_else(|| "root".into())
}
fn mysqladmin(args: &[&str]) -> Cmd {
Cmd::new("mysqladmin").arg("-u").arg(mysql_user(&|var| std::env::var(var).ok())).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 = &proj[..proj.len().min(max_proj)];
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");
}
fn env_of<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| v.to_string())
}
#[test]
fn url_reads_the_process_environment() {
let process = |var: &str| std::env::var(var).ok();
for engine in [DbEngine::Postgres, DbEngine::Mysql] {
assert_eq!(engine.url("db"), engine.url_in("db", &process));
}
assert!(DbEngine::Postgres.url("db").starts_with("postgresql://"));
assert!(DbEngine::Mysql.url("db").starts_with("mysql://"));
}
#[test]
fn postgres_url_is_built_from_the_pg_variables() {
let env =
[("PGHOST", "db.local"), ("PGPORT", "5433"), ("PGUSER", "app"), ("PGPASSWORD", "s@cret"), ("USER", "os")];
assert_eq!(DbEngine::Postgres.url_in("t", &env_of(&env)), "postgresql://app:s%40cret@db.local:5433/t");
let env = [("PGHOST", "/tmp"), ("USER", "os")];
assert_eq!(DbEngine::Postgres.url_in("t", &env_of(&env)), "postgresql://os@localhost/t");
assert_eq!(DbEngine::Postgres.url_in("t", &env_of(&[])), "postgresql://localhost/t");
}
#[test]
fn mysql_url_is_built_from_the_mysql_variables() {
let env = [
("MYSQL_HOST", "db.local"),
("MYSQL_TCP_PORT", "3307"),
("MYSQL_USER", "app"),
("MYSQL_PWD", "pw"),
("USER", "os"),
];
assert_eq!(DbEngine::Mysql.url_in("t", &env_of(&env)), "mysql://app:pw@db.local:3307/t");
let env = [("MYSQL_HOST", "/var/run/mysqld.sock"), ("MYSQL_TCP_PORT", ""), ("USER", "os")];
assert_eq!(DbEngine::Mysql.url_in("t", &env_of(&env)), "mysql://os@127.0.0.1:3306/t");
assert_eq!(DbEngine::Mysql.url_in("t", &env_of(&[])), "mysql://root@127.0.0.1:3306/t");
}
proptest::proptest! {
#[test]
fn mysql_url_carries_any_given_port(port in "[0-9]{1,5}") {
let env = [("MYSQL_TCP_PORT", port.as_str()), ("MYSQL_USER", "u")];
proptest::prop_assert_eq!(DbEngine::Mysql.url_in("t", &env_of(&env)), format!("mysql://u@127.0.0.1:{port}/t"));
}
}
#[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())), "");
assert_eq!(auth_prefix("bob", Some(String::new())), "bob@");
}
#[test]
fn venv_python_prefers_the_workspace_venv() {
let ws = tempfile::tempdir().unwrap();
assert_eq!(venv_python(ws.path()), PathBuf::from("python3"));
std::fs::create_dir_all(ws.path().join(".venv/bin")).unwrap();
std::fs::write(ws.path().join(".venv/bin/python"), "").unwrap();
assert_eq!(venv_python(ws.path()), ws.path().join(".venv/bin/python"));
}
#[test]
fn provisioners_run_venv_repair_before_the_python_frameworks() {
let names: Vec<&str> = provisioners().iter().map(|p| p.name()).collect();
assert_eq!(names, ["python-venv", "rails", "prisma", "alembic", "django", "laravel", "ef-core", "phoenix"]);
}
#[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"));
assert_eq!(name, format!("{}_ws_abc123_test", "a".repeat(63 - "_ws_abc123_test".len())));
let exact = "b".repeat(63 - "_ws_abc123_test".len());
assert_eq!(test_db_name(&exact, "ws-abc123"), format!("{exact}_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");
}
}