1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use anyhow::{bail, Result};
11use serde::{Deserialize, Serialize};
12use vcs_runner::Cmd;
13
14pub(crate) mod alembic;
15mod django;
16pub(crate) mod ef_core;
17pub(crate) mod laravel;
18pub(crate) mod phoenix;
19pub(crate) mod prisma;
20mod python_venv;
21pub(crate) mod rails;
22
23pub trait Provisioner: Send + Sync {
24 fn name(&self) -> &'static str;
25
26 fn detect(&self, ws_dir: &Path) -> bool;
29
30 fn setup(&self, ctx: &ProvisionCtx<'_>) -> Result<Setup>;
34}
35
36pub struct ProvisionCtx<'a> {
37 pub project_dir: &'a Path,
39 pub project_name: &'a str,
40 pub ws_id: &'a str,
41 pub ws_dir: &'a Path,
42 pub mise_vars: &'a HashMap<String, String>,
43}
44
45#[derive(Default)]
46pub struct Setup {
47 pub resources: Vec<Resource>,
49 pub env: Vec<(String, String)>,
52 pub session_env: Vec<(String, String)>,
59 pub env_file: Option<String>,
62 pub failed_steps: Vec<String>,
65}
66
67impl Setup {
68 pub(crate) fn database_url(resources: Vec<Resource>, url: String, failed_steps: Vec<String>) -> Self {
72 Self { resources, env: vec![("DATABASE_URL".to_string(), url)], failed_steps, ..Self::default() }
73 }
74
75 pub(crate) fn database_not_created(db: &str, reason: &dyn std::fmt::Display) -> Self {
78 eprintln!("Warning: could not create test database {db}: {reason}");
79 Self { failed_steps: vec![format!("create test database {db}")], ..Self::default() }
80 }
81}
82
83#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
86#[serde(tag = "type", rename_all = "snake_case")]
87pub enum Resource {
88 PostgresDb { name: String },
89 MysqlDb { name: String },
90}
91
92impl Resource {
93 pub fn teardown(&self) {
94 match self {
95 Resource::PostgresDb { name } => {
96 let _ = Cmd::new("dropdb").arg(name).run();
97 eprintln!("Dropped test database {name}");
98 }
99 Resource::MysqlDb { name } => {
100 let _ = mysqladmin(&["--force", "drop", name]).run();
101 eprintln!("Dropped test database {name}");
102 }
103 }
104 }
105
106 pub fn db_name(&self) -> &str {
108 match self {
109 Resource::PostgresDb { name } | Resource::MysqlDb { name } => name,
110 }
111 }
112}
113
114pub enum DbEngine {
117 Postgres,
118 Mysql,
119}
120
121impl DbEngine {
122 pub fn create(&self, name: &str) -> Result<()> {
123 match self {
124 DbEngine::Postgres => {
125 if Cmd::new("createdb").arg(name).run().is_ok() {
126 Ok(())
127 } else {
128 bail!("createdb {name} failed (is the Postgres server running?)")
129 }
130 }
131 DbEngine::Mysql => {
132 if mysqladmin(&["create", name]).run().is_ok() {
133 Ok(())
134 } else {
135 bail!("mysqladmin create {name} failed (is the MySQL server running?)")
136 }
137 }
138 }
139 }
140
141 pub fn url(&self, name: &str) -> String {
147 self.url_in(name, &|var| std::env::var(var).ok())
148 }
149
150 fn url_in(&self, name: &str, env: &dyn Fn(&str) -> Option<String>) -> String {
152 match self {
153 DbEngine::Postgres => {
154 let host = env_host(env("PGHOST"), "localhost");
155 let user = env("PGUSER").or_else(|| env("USER")).unwrap_or_default();
156 let auth = auth_prefix(&user, env("PGPASSWORD"));
157 postgres_url(&auth, &host, env("PGPORT").as_deref(), name)
158 }
159 DbEngine::Mysql => {
160 let host = env_host(env("MYSQL_HOST"), "127.0.0.1");
161 let port = env("MYSQL_TCP_PORT").filter(|p| !p.is_empty());
162 let user = mysql_user(env);
163 let auth = auth_prefix(&user, env("MYSQL_PWD"));
164 format!("mysql://{auth}{}/{name}", url_authority(&host, Some(port.as_deref().unwrap_or("3306"))))
165 }
166 }
167 }
168
169 pub fn resource(&self, name: &str) -> Resource {
170 match self {
171 DbEngine::Postgres => Resource::PostgresDb { name: name.to_string() },
172 DbEngine::Mysql => Resource::MysqlDb { name: name.to_string() },
173 }
174 }
175}
176
177pub(crate) fn postgres_url(auth: &str, host: &str, port: Option<&str>, name: &str) -> String {
180 format!("postgresql://{auth}{}/{name}", url_authority(host, port))
181}
182
183pub(crate) fn url_authority(host: &str, port: Option<&str>) -> String {
186 let host = if host.contains(':') && !host.starts_with('[') { format!("[{host}]") } else { host.to_string() };
187 match port.filter(|p| !p.is_empty()) {
188 Some(p) => format!("{host}:{p}"),
189 None => host,
190 }
191}
192
193pub(crate) fn run_step(cmd: Cmd, what: &str) -> Option<String> {
198 match cmd.run() {
199 Ok(_) => None,
200 Err(e) => {
201 eprintln!("Warning: {what} failed: {e}");
202 Some(what.to_string())
203 }
204 }
205}
206
207fn env_host(value: Option<String>, default: &str) -> String {
208 let host = value.unwrap_or_default();
209 if host.is_empty() || host.starts_with('/') {
210 default.to_string()
211 } else {
212 host
213 }
214}
215
216pub(crate) fn auth_prefix(user: &str, password: Option<String>) -> String {
217 if user.is_empty() {
218 return String::new();
222 }
223 let user = percent_encode_userinfo(user);
224 match password {
225 Some(p) if !p.is_empty() => format!("{user}:{}@", percent_encode_userinfo(&p)),
226 _ => format!("{user}@"),
227 }
228}
229
230pub(crate) fn percent_encode_userinfo(s: &str) -> String {
236 let mut out = String::with_capacity(s.len());
237 for b in s.bytes() {
238 match b {
239 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => out.push(b as char),
240 _ => out.push_str(&format!("%{b:02X}")),
241 }
242 }
243 out
244}
245
246fn mysql_user(env: &dyn Fn(&str) -> Option<String>) -> String {
249 env("MYSQL_USER").or_else(|| env("USER")).unwrap_or_else(|| "root".into())
250}
251
252fn mysqladmin(args: &[&str]) -> Cmd {
255 Cmd::new("mysqladmin").arg("-u").arg(mysql_user(&|var| std::env::var(var).ok())).args(args)
256}
257
258pub fn test_db_name(project_name: &str, ws_id: &str) -> String {
263 let sanitize =
264 |s: &str| s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' }).collect::<String>();
265 let suffix = format!("_{}_test", sanitize(ws_id));
266 let proj = sanitize(project_name);
267 let max_proj = 63usize.saturating_sub(suffix.len());
268 let proj = &proj[..proj.len().min(max_proj)];
270 format!("{proj}{suffix}")
271}
272
273pub fn venv_python(ws_dir: &Path) -> PathBuf {
277 let venv = ws_dir.join(".venv/bin/python");
278 if venv.is_file() {
279 venv
280 } else {
281 PathBuf::from("python3")
282 }
283}
284
285pub fn provisioners() -> Vec<Box<dyn Provisioner>> {
288 vec![
291 Box::new(python_venv::PythonVenv),
292 Box::new(rails::Rails),
293 Box::new(prisma::Prisma),
294 Box::new(alembic::Alembic),
295 Box::new(django::Django),
296 Box::new(laravel::Laravel),
297 Box::new(ef_core::EfCore),
298 Box::new(phoenix::Phoenix),
299 ]
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn run_step_reports_whether_the_step_succeeded() {
308 assert_eq!(run_step(Cmd::new("true"), "a passing step"), None);
309 assert_eq!(run_step(Cmd::new("false"), "a failing step").as_deref(), Some("a failing step"));
310 assert_eq!(run_step(Cmd::new("workon-no-such-binary"), "a missing tool").as_deref(), Some("a missing tool"));
311 }
312
313 #[test]
315 fn database_url_setup_carries_the_database_its_url_and_failed_steps() {
316 let db = Resource::PostgresDb { name: "proj_ws_test".into() };
317 let setup = Setup::database_url(
318 vec![db.clone()],
319 "postgresql://u@localhost/proj_ws_test".into(),
320 vec!["migrate".into()],
321 );
322
323 assert_eq!(setup.resources, vec![db]);
324 assert_eq!(setup.env, vec![("DATABASE_URL".to_string(), "postgresql://u@localhost/proj_ws_test".to_string())]);
325 assert_eq!(setup.failed_steps, vec!["migrate".to_string()]);
326 assert!(setup.session_env.is_empty(), "a URL never goes in the session env");
327 assert_eq!(setup.env_file, None, "the default .env.test.local");
328 }
329
330 #[test]
331 fn a_database_not_created_is_a_failed_step_with_nothing_to_tear_down() {
332 let setup = Setup::database_not_created("proj_ws_test", &"connection refused");
333 assert_eq!(setup.failed_steps, ["create test database proj_ws_test"]);
334 assert!(setup.resources.is_empty() && setup.env.is_empty() && setup.session_env.is_empty());
335 }
336
337 #[test]
338 fn postgres_url_carries_pgport_and_brackets_an_ipv6_host() {
339 assert_eq!(postgres_url("u@", "localhost", Some("5433"), "db"), "postgresql://u@localhost:5433/db");
342 assert_eq!(postgres_url("u@", "localhost", None, "db"), "postgresql://u@localhost/db");
343 assert_eq!(postgres_url("", "db.local", Some(""), "db"), "postgresql://db.local/db");
344 assert_eq!(postgres_url("u@", "::1", Some("5432"), "db"), "postgresql://u@[::1]:5432/db");
346 assert_eq!(url_authority("[::1]", None), "[::1]");
347 assert_eq!(url_authority("fe80::1", Some("3306")), "[fe80::1]:3306");
348 }
349
350 fn env_of<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
351 move |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| v.to_string())
352 }
353
354 #[test]
355 fn url_reads_the_process_environment() {
356 let process = |var: &str| std::env::var(var).ok();
357 for engine in [DbEngine::Postgres, DbEngine::Mysql] {
358 assert_eq!(engine.url("db"), engine.url_in("db", &process));
359 }
360 assert!(DbEngine::Postgres.url("db").starts_with("postgresql://"));
361 assert!(DbEngine::Mysql.url("db").starts_with("mysql://"));
362 }
363
364 #[test]
365 fn postgres_url_is_built_from_the_pg_variables() {
366 let env =
367 [("PGHOST", "db.local"), ("PGPORT", "5433"), ("PGUSER", "app"), ("PGPASSWORD", "s@cret"), ("USER", "os")];
368 assert_eq!(DbEngine::Postgres.url_in("t", &env_of(&env)), "postgresql://app:s%40cret@db.local:5433/t");
369 let env = [("PGHOST", "/tmp"), ("USER", "os")];
371 assert_eq!(DbEngine::Postgres.url_in("t", &env_of(&env)), "postgresql://os@localhost/t");
372 assert_eq!(DbEngine::Postgres.url_in("t", &env_of(&[])), "postgresql://localhost/t");
373 }
374
375 #[test]
376 fn mysql_url_is_built_from_the_mysql_variables() {
377 let env = [
378 ("MYSQL_HOST", "db.local"),
379 ("MYSQL_TCP_PORT", "3307"),
380 ("MYSQL_USER", "app"),
381 ("MYSQL_PWD", "pw"),
382 ("USER", "os"),
383 ];
384 assert_eq!(DbEngine::Mysql.url_in("t", &env_of(&env)), "mysql://app:pw@db.local:3307/t");
385 let env = [("MYSQL_HOST", "/var/run/mysqld.sock"), ("MYSQL_TCP_PORT", ""), ("USER", "os")];
387 assert_eq!(DbEngine::Mysql.url_in("t", &env_of(&env)), "mysql://os@127.0.0.1:3306/t");
388 assert_eq!(DbEngine::Mysql.url_in("t", &env_of(&[])), "mysql://root@127.0.0.1:3306/t");
389 }
390
391 proptest::proptest! {
392 #[test]
394 fn mysql_url_carries_any_given_port(port in "[0-9]{1,5}") {
395 let env = [("MYSQL_TCP_PORT", port.as_str()), ("MYSQL_USER", "u")];
396 proptest::prop_assert_eq!(DbEngine::Mysql.url_in("t", &env_of(&env)), format!("mysql://u@127.0.0.1:{port}/t"));
397 }
398 }
399
400 #[test]
401 fn userinfo_is_percent_encoded_in_urls() {
402 assert_eq!(percent_encode_userinfo("p@ss:w0/d#x"), "p%40ss%3Aw0%2Fd%23x");
403 assert_eq!(percent_encode_userinfo("plain-user_1.2~3"), "plain-user_1.2~3");
404 assert_eq!(auth_prefix("us er", Some("p@w:d".to_string())), "us%20er:p%40w%3Ad@");
407 assert_eq!(auth_prefix("bob", None), "bob@");
408 assert_eq!(auth_prefix("", Some("x".to_string())), "");
409 assert_eq!(auth_prefix("bob", Some(String::new())), "bob@");
411 }
412
413 #[test]
414 fn venv_python_prefers_the_workspace_venv() {
415 let ws = tempfile::tempdir().unwrap();
416 assert_eq!(venv_python(ws.path()), PathBuf::from("python3"));
417 std::fs::create_dir_all(ws.path().join(".venv/bin")).unwrap();
418 std::fs::write(ws.path().join(".venv/bin/python"), "").unwrap();
419 assert_eq!(venv_python(ws.path()), ws.path().join(".venv/bin/python"));
420 }
421
422 #[test]
423 fn provisioners_run_venv_repair_before_the_python_frameworks() {
424 let names: Vec<&str> = provisioners().iter().map(|p| p.name()).collect();
425 assert_eq!(names, ["python-venv", "rails", "prisma", "alembic", "django", "laravel", "ef-core", "phoenix"]);
426 }
427
428 #[test]
429 fn test_db_name_matches_legacy_for_normal_names() {
430 assert_eq!(test_db_name("acme", "ws-abc123"), "acme_ws_abc123_test");
431 assert_eq!(test_db_name("my-app", "ws-abc123-fix-bug"), "my_app_ws_abc123_fix_bug_test");
432 }
433
434 #[test]
435 fn test_db_name_stays_within_63_bytes() {
436 let long = "a".repeat(200);
437 let name = test_db_name(&long, "ws-abc123");
438 assert!(name.len() <= 63, "len {} > 63: {name}", name.len());
439 assert!(name.ends_with("_ws_abc123_test"));
441 assert_eq!(name, format!("{}_ws_abc123_test", "a".repeat(63 - "_ws_abc123_test".len())));
442 let exact = "b".repeat(63 - "_ws_abc123_test".len());
444 assert_eq!(test_db_name(&exact, "ws-abc123"), format!("{exact}_ws_abc123_test"));
445 }
446
447 #[test]
448 fn resource_round_trips_through_json() {
449 let r = Resource::PostgresDb { name: "acme_ws_abc_test".into() };
450 let json = serde_json::to_string(&r).unwrap();
451 assert_eq!(json, r#"{"type":"postgres_db","name":"acme_ws_abc_test"}"#);
452 assert_eq!(serde_json::from_str::<Resource>(&json).unwrap(), r);
453 }
454
455 #[test]
459 fn postgres_create_and_drop_roundtrip() {
460 let name = "workon_provision_selftest_db";
461 Resource::PostgresDb { name: name.into() }.teardown();
463 if DbEngine::Postgres.create(name).is_err() {
464 return; }
466 Resource::PostgresDb { name: name.into() }.teardown();
468 let recreated = DbEngine::Postgres.create(name).is_ok();
469 Resource::PostgresDb { name: name.into() }.teardown(); assert!(recreated, "dropdb should have removed the DB so createdb succeeds again");
471 }
472
473 #[test]
476 fn mysql_create_and_drop_roundtrip() {
477 let name = "workon_provision_selftest_mysql";
478 Resource::MysqlDb { name: name.into() }.teardown();
479 if DbEngine::Mysql.create(name).is_err() {
480 return; }
482 Resource::MysqlDb { name: name.into() }.teardown();
483 let recreated = DbEngine::Mysql.create(name).is_ok();
484 Resource::MysqlDb { name: name.into() }.teardown();
485 assert!(recreated, "mysqladmin drop should have removed the DB so create succeeds again");
486 }
487}