Skip to main content

workon/provision/
mod.rs

1//! Per-workspace provisioners: modular setup that copying gitignored files can't
2//! provide (isolated test databases today; more per ecosystem later). Each
3//! provisioner detects its project type and does only the irreducible work,
4//! recording any external resource it created so teardown can undo it. Mirrors
5//! the `vcs` trait + backend registry. See specs/workspace-provisioners.md.
6
7use 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    /// Detect from the (already-copied) worktree. May read a config file to
27    /// decide (e.g. a sqlite datasource means there's no server DB to manage).
28    fn detect(&self, ws_dir: &Path) -> bool;
29
30    /// Do the irreducible setup. Returns resources to tear down and env to write
31    /// to the workspace's generated env file. An empty `Setup` is a valid,
32    /// intentional no-op (e.g. a framework that manages its own test DB).
33    fn setup(&self, ctx: &ProvisionCtx<'_>) -> Result<Setup>;
34}
35
36pub struct ProvisionCtx<'a> {
37    /// Source repo — the old path venv/editable repair rewrites (future).
38    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    /// External resources teardown must undo (empty when framework-managed).
48    pub resources: Vec<Resource>,
49    /// Vars to write to the workspace's generated env file, which the framework
50    /// loads only in its test environment.
51    pub env: Vec<(String, String)>,
52    /// Vars to inject into the workspace *session* (merged with mise env on
53    /// attach), for a framework whose test var is safe session-wide because it
54    /// only takes effect under that framework's test env — Phoenix's
55    /// `MIX_TEST_PARTITION` (read only under `MIX_ENV=test`). Distinct from `env`:
56    /// a session-wide `DATABASE_URL` would wrongly point every dev command at the
57    /// test DB, so URLs go in `env` (test-file-scoped) and never here.
58    pub session_env: Vec<(String, String)>,
59    /// Which generated env file the vars go in. `None` = `.env.test.local`
60    /// (Rails/dotenv default); Laravel needs `.env.testing`, etc.
61    pub env_file: Option<String>,
62    /// Setup steps that ran and failed (`rails db:schema:load`, …). The resources and env
63    /// above still stand, but the workspace is not ready, and `create` says so (issue #2).
64    pub failed_steps: Vec<String>,
65}
66
67impl Setup {
68    /// A test database reached through `DATABASE_URL` in the generated env file: what
69    /// Rails, Alembic and Django return once the database exists. Kept apart from
70    /// their `setup`, which needs a live server, so each field can be tested.
71    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    /// A test database that could not be created: nothing to tear down and no env to write, so
76    /// the workspace's tests would reach the shared database. A failed step, with its reason.
77    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/// Something a provisioner created that teardown must undo. Serialized into
84/// `.workon.json` so a fresh-process `destroy` can undo it.
85#[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    /// The DB name, for reporting (`destroy --json`).
107    pub fn db_name(&self) -> &str {
108        match self {
109            Resource::PostgresDb { name } | Resource::MysqlDb { name } => name,
110        }
111    }
112}
113
114/// A test-database engine. SQLite is never a `DbEngine` — it's a file, handled by
115/// each provisioner as a no-op.
116pub 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    /// A connection URL for the test DB, built from the client environment with
142    /// OS-user fallback, because URL-driven clients (Prisma, dj-database-url, …)
143    /// don't apply libpq/libmysql's implicit user default — a userless URL is
144    /// rejected. `PG*` for Postgres; `MYSQL_HOST`/`MYSQL_TCP_PORT`/`MYSQL_USER`/
145    /// `MYSQL_PWD` for MySQL. A socket-path host becomes `localhost`.
146    pub fn url(&self, name: &str) -> String {
147        self.url_in(name, &|var| std::env::var(var).ok())
148    }
149
150    /// [`Self::url`] with the variables read through `env`, so a test can supply them.
151    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
177/// `PGPORT` is included when set: libpq's createdb honours it, and a URL
178/// without it points the app at 5432 whatever server the database was made on.
179pub(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
183/// `host[:port]` for a URL. An IPv6 literal is bracketed, or its colons would
184/// read as a port.
185pub(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
193/// Run a setup step (a migration, a client generate) whose failure does not stop
194/// provisioning but must be seen: swallowed, a failed migration leaves a workspace
195/// that looks provisioned and has no schema. Returns `what` when it failed, for
196/// [`Setup::failed_steps`].
197pub(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        // No username (PG*/MYSQL* user and $USER all unset) → no userinfo. Any
219        // password is intentionally dropped: a password without a user isn't a
220        // valid URL credential, and libpq/libmysql wouldn't emit one either.
221        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
230/// Percent-encode a URL userinfo component (username or password). A credential
231/// containing `@`, `:`, `/`, `#`, `?`, `%`, … would otherwise be misparsed —
232/// the `:` splits user/pass and the `@` ends the userinfo. Encodes every byte
233/// outside the RFC 3986 unreserved set, which is always safe (over-encoding a
234/// sub-delim just decodes back).
235pub(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
246/// MySQL clients don't read a user env var natively (unlike host/port/password),
247/// so workon reads `MYSQL_USER`, falling back to the OS user then `root`.
248fn mysql_user(env: &dyn Fn(&str) -> Option<String>) -> String {
249    env("MYSQL_USER").or_else(|| env("USER")).unwrap_or_else(|| "root".into())
250}
251
252/// `mysqladmin` with the resolved user in front of the subcommand; host, port,
253/// and password are read from the environment natively.
254fn mysqladmin(args: &[&str]) -> Cmd {
255    Cmd::new("mysqladmin").arg("-u").arg(mysql_user(&|var| std::env::var(var).ok())).args(args)
256}
257
258/// A collision-free test DB name that stays within Postgres's 63-byte identifier
259/// limit. The random `ws_id` (which carries the uniqueness) and the `_test`
260/// suffix are always kept; the project name is truncated if the whole would
261/// overflow. Matches today's `{project}_{ws_id}_test` for normal-length names.
262pub 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    // `sanitize` leaves only ASCII, so a byte index is a char boundary.
269    let proj = &proj[..proj.len().min(max_proj)];
270    format!("{proj}{suffix}")
271}
272
273/// The Python interpreter to run project tools with: the workspace's `.venv`
274/// (which `PythonVenv` has already repaired, since it runs first) if present,
275/// else `python3` on `PATH`.
276pub 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
285/// The ordered provisioner registry. Order matters (future: venv repair before
286/// Python DB frameworks).
287pub fn provisioners() -> Vec<Box<dyn Provisioner>> {
288    // Order matters: venv repair before any Python DB framework, which needs a
289    // working interpreter.
290    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    /// Each field is checked on its own, so dropping any of them from the builder fails.
314    #[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        // PGPORT was dropped, so a server on 5433 got a URL for 5432 while createdb
340        // (which reads PGPORT itself) made the database on 5433.
341        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        // An unbracketed IPv6 host's colons would read as a port.
345        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        // No PGUSER: the OS user; a socket-path PGHOST: localhost, since a URL needs TCP.
370        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        // An empty port is MySQL's default, not an empty `host:`.
386        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        /// Any MYSQL_TCP_PORT that is set and non-empty is the URL's port.
393        #[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        // auth_prefix encodes both components while keeping the `:` and `@` that
405        // structure the userinfo, so a credential with reserved chars is safe.
406        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        // An empty password is no password, not a `bob:` with nothing after it.
410        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        // The unique ws_id + suffix survive; the project is what gets trimmed.
440        assert!(name.ends_with("_ws_abc123_test"));
441        assert_eq!(name, format!("{}_ws_abc123_test", "a".repeat(63 - "_ws_abc123_test".len())));
442        // A project that exactly fills the remaining bytes is kept whole.
443        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    /// Real create + drop against Postgres. Skips (does not fail) when no server
456    /// is reachable — like the jj tests gate on `jj_available()`. Runs in CI,
457    /// which stands up a Postgres service.
458    #[test]
459    fn postgres_create_and_drop_roundtrip() {
460        let name = "workon_provision_selftest_db";
461        // Clean any leftover from a previous aborted run, then try to create.
462        Resource::PostgresDb { name: name.into() }.teardown();
463        if DbEngine::Postgres.create(name).is_err() {
464            return; // no reachable Postgres server
465        }
466        // Drop it; a fresh create must then succeed — proving the drop worked.
467        Resource::PostgresDb { name: name.into() }.teardown();
468        let recreated = DbEngine::Postgres.create(name).is_ok();
469        Resource::PostgresDb { name: name.into() }.teardown(); // final cleanup
470        assert!(recreated, "dropdb should have removed the DB so createdb succeeds again");
471    }
472
473    /// Real create + drop against MySQL. Skips unless a server is reachable
474    /// (needs `mysqladmin` + MYSQL_* env pointing at it). Runs in CI's MySQL job.
475    #[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; // no reachable MySQL server
481        }
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}