workon 0.24.6

Development workspace launcher with Zellij, Claude CLI, and branchdiff
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
//! Per-workspace provisioners: modular setup that copying gitignored files can't
//! provide (isolated test databases today; more per ecosystem later). Each
//! provisioner detects its project type and does only the irreducible work,
//! recording any external resource it created so teardown can undo it. Mirrors
//! the `vcs` trait + backend registry. See specs/workspace-provisioners.md.

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;

    /// Detect from the (already-copied) worktree. May read a config file to
    /// decide (e.g. a sqlite datasource means there's no server DB to manage).
    fn detect(&self, ws_dir: &Path) -> bool;

    /// Do the irreducible setup. Returns resources to tear down and env to write
    /// to the workspace's generated env file. An empty `Setup` is a valid,
    /// intentional no-op (e.g. a framework that manages its own test DB).
    fn setup(&self, ctx: &ProvisionCtx<'_>) -> Result<Setup>;
}

pub struct ProvisionCtx<'a> {
    /// Source repo — the old path venv/editable repair rewrites (future).
    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 {
    /// External resources teardown must undo (empty when framework-managed).
    pub resources: Vec<Resource>,
    /// Vars to write to the workspace's generated env file, which the framework
    /// loads only in its test environment.
    pub env: Vec<(String, String)>,
    /// Vars to inject into the workspace *session* (merged with mise env on
    /// attach), for a framework whose test var is safe session-wide because it
    /// only takes effect under that framework's test env — Phoenix's
    /// `MIX_TEST_PARTITION` (read only under `MIX_ENV=test`). Distinct from `env`:
    /// a session-wide `DATABASE_URL` would wrongly point every dev command at the
    /// test DB, so URLs go in `env` (test-file-scoped) and never here.
    pub session_env: Vec<(String, String)>,
    /// Which generated env file the vars go in. `None` = `.env.test.local`
    /// (Rails/dotenv default); Laravel needs `.env.testing`, etc.
    pub env_file: Option<String>,
}

/// Something a provisioner created that teardown must undo. Serialized into
/// `.workon.json` so a fresh-process `destroy` can undo it.
#[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}");
            }
        }
    }

    /// The DB name, for reporting (`destroy --json`).
    pub fn db_name(&self) -> &str {
        match self {
            Resource::PostgresDb { name } | Resource::MysqlDb { name } => name,
        }
    }
}

/// A test-database engine. SQLite is never a `DbEngine` — it's a file, handled by
/// each provisioner as a no-op.
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?)")
                }
            }
        }
    }

    /// A connection URL for the test DB, built from the client environment with
    /// OS-user fallback, because URL-driven clients (Prisma, dj-database-url, …)
    /// don't apply libpq/libmysql's implicit user default — a userless URL is
    /// rejected. `PG*` for Postgres; `MYSQL_HOST`/`MYSQL_TCP_PORT`/`MYSQL_USER`/
    /// `MYSQL_PWD` for MySQL. A socket-path host becomes `localhost`.
    pub fn url(&self, name: &str) -> String {
        self.url_in(name, &|var| std::env::var(var).ok())
    }

    /// [`Self::url`] with the variables read through `env`, so a test can supply them.
    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() },
        }
    }
}

/// `PGPORT` is included when set: libpq's createdb honours it, and a URL
/// without it points the app at 5432 whatever server the database was made on.
pub(crate) fn postgres_url(auth: &str, host: &str, port: Option<&str>, name: &str) -> String {
    format!("postgresql://{auth}{}/{name}", url_authority(host, port))
}

/// `host[:port]` for a URL. An IPv6 literal is bracketed, or its colons would
/// read as a 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,
    }
}

/// Run a setup step (a migration, a client generate) whose failure does not stop
/// provisioning but must be seen: swallowed, a failed migration leaves a workspace
/// that looks provisioned and has no schema. Returns whether it succeeded.
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() {
        // No username (PG*/MYSQL* user and $USER all unset) → no userinfo. Any
        // password is intentionally dropped: a password without a user isn't a
        // valid URL credential, and libpq/libmysql wouldn't emit one either.
        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}@"),
    }
}

/// Percent-encode a URL userinfo component (username or password). A credential
/// containing `@`, `:`, `/`, `#`, `?`, `%`, … would otherwise be misparsed —
/// the `:` splits user/pass and the `@` ends the userinfo. Encodes every byte
/// outside the RFC 3986 unreserved set, which is always safe (over-encoding a
/// sub-delim just decodes back).
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
}

/// MySQL clients don't read a user env var natively (unlike host/port/password),
/// so workon reads `MYSQL_USER`, falling back to the OS user then `root`.
fn mysql_user(env: &dyn Fn(&str) -> Option<String>) -> String {
    env("MYSQL_USER").or_else(|| env("USER")).unwrap_or_else(|| "root".into())
}

/// `mysqladmin` with the resolved user in front of the subcommand; host, port,
/// and password are read from the environment natively.
fn mysqladmin(args: &[&str]) -> Cmd {
    Cmd::new("mysqladmin").arg("-u").arg(mysql_user(&|var| std::env::var(var).ok())).args(args)
}

/// A collision-free test DB name that stays within Postgres's 63-byte identifier
/// limit. The random `ws_id` (which carries the uniqueness) and the `_test`
/// suffix are always kept; the project name is truncated if the whole would
/// overflow. Matches today's `{project}_{ws_id}_test` for normal-length names.
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());
    // `sanitize` leaves only ASCII, so a byte index is a char boundary.
    let proj = &proj[..proj.len().min(max_proj)];
    format!("{proj}{suffix}")
}

/// The Python interpreter to run project tools with: the workspace's `.venv`
/// (which `PythonVenv` has already repaired, since it runs first) if present,
/// else `python3` on `PATH`.
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")
    }
}

/// The ordered provisioner registry. Order matters (future: venv repair before
/// Python DB frameworks).
pub fn provisioners() -> Vec<Box<dyn Provisioner>> {
    // Order matters: venv repair before any Python DB framework, which needs a
    // working interpreter.
    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() {
        // PGPORT was dropped, so a server on 5433 got a URL for 5432 while createdb
        // (which reads PGPORT itself) made the database on 5433.
        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");
        // An unbracketed IPv6 host's colons would read as a port.
        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");
        // No PGUSER: the OS user; a socket-path PGHOST: localhost, since a URL needs TCP.
        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");
        // An empty port is MySQL's default, not an empty `host:`.
        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! {
        /// Any MYSQL_TCP_PORT that is set and non-empty is the URL's port.
        #[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");
        // auth_prefix encodes both components while keeping the `:` and `@` that
        // structure the userinfo, so a credential with reserved chars is safe.
        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())), "");
        // An empty password is no password, not a `bob:` with nothing after it.
        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());
        // The unique ws_id + suffix survive; the project is what gets trimmed.
        assert!(name.ends_with("_ws_abc123_test"));
        assert_eq!(name, format!("{}_ws_abc123_test", "a".repeat(63 - "_ws_abc123_test".len())));
        // A project that exactly fills the remaining bytes is kept whole.
        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);
    }

    /// Real create + drop against Postgres. Skips (does not fail) when no server
    /// is reachable — like the jj tests gate on `jj_available()`. Runs in CI,
    /// which stands up a Postgres service.
    #[test]
    fn postgres_create_and_drop_roundtrip() {
        let name = "workon_provision_selftest_db";
        // Clean any leftover from a previous aborted run, then try to create.
        Resource::PostgresDb { name: name.into() }.teardown();
        if DbEngine::Postgres.create(name).is_err() {
            return; // no reachable Postgres server
        }
        // Drop it; a fresh create must then succeed — proving the drop worked.
        Resource::PostgresDb { name: name.into() }.teardown();
        let recreated = DbEngine::Postgres.create(name).is_ok();
        Resource::PostgresDb { name: name.into() }.teardown(); // final cleanup
        assert!(recreated, "dropdb should have removed the DB so createdb succeeds again");
    }

    /// Real create + drop against MySQL. Skips unless a server is reachable
    /// (needs `mysqladmin` + MYSQL_* env pointing at it). Runs in CI's MySQL job.
    #[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; // no reachable MySQL server
        }
        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");
    }
}