workon 0.27.0

Development workspace launcher with Zellij, Claude CLI, and branchdiff
Documentation
//! Phoenix / Ecto provisioner (Elixir): an isolated Postgres test database per
//! workspace, created and migrated.
//!
//! Phoenix's generated test config names the DB `#{app}_test#{MIX_TEST_PARTITION}`,
//! so a per-workspace `MIX_TEST_PARTITION` isolates it with zero file edits.
//! workon runs `MIX_ENV=test mix ecto.create && ecto.migrate` with that partition
//! so the DB exists at provision time, and records the resulting name for
//! teardown.
//!
//! For the user's own `mix test` to reach the same DB, `MIX_TEST_PARTITION` has
//! to be set when they run it, so it's returned as `Setup.session_env` and
//! injected into the workspace session on attach (safe session-wide — it's read
//! only under `MIX_ENV=test`). Detection keys on `:ecto_sql` (a `--no-ecto`
//! Phoenix app has no DB).

use std::path::Path;

use anyhow::Result;
use vcs_runner::Cmd;

use super::{run_step, DbEngine, ProvisionCtx, Provisioner, Setup};

pub struct Phoenix;

impl Provisioner for Phoenix {
    fn name(&self) -> &'static str {
        "phoenix"
    }

    fn detect(&self, ws_dir: &Path) -> bool {
        std::fs::read_to_string(ws_dir.join("mix.exs")).map(|s| s.contains(":ecto_sql")).unwrap_or(false)
    }

    fn setup(&self, ctx: &ProvisionCtx<'_>) -> Result<Setup> {
        let mix = std::fs::read_to_string(ctx.ws_dir.join("mix.exs")).unwrap_or_default();
        let Some(app) = app_name(&mix) else {
            eprintln!("Phoenix: could not read the app name from mix.exs; skipping");
            return Ok(Setup::default());
        };

        let partition = partition_for(&app, ctx.ws_id);
        let db = format!("{app}_test{partition}");

        eprintln!("Creating test database {db} (MIX_TEST_PARTITION={partition})...");
        let mut create = Cmd::new("mix")
            .args(["ecto.create", "--quiet"])
            .env("MIX_ENV", "test")
            .env("MIX_TEST_PARTITION", &partition)
            .in_dir(ctx.ws_dir);
        for (k, v) in ctx.mise_vars {
            create = create.env(k, v);
        }
        if let Err(e) = create.run() {
            return Ok(Setup::database_not_created(&db, &e));
        }
        // The DB exists now — record it before migrating (resource-before-risk).
        let resource = DbEngine::Postgres.resource(&db);

        let mut migrate = Cmd::new("mix")
            .args(["ecto.migrate", "--quiet"])
            .env("MIX_ENV", "test")
            .env("MIX_TEST_PARTITION", &partition)
            .in_dir(ctx.ws_dir);
        for (k, v) in ctx.mise_vars {
            migrate = migrate.env(k, v);
        }
        let failed_steps = run_step(migrate, "mix ecto.migrate").into_iter().collect();

        // Session env, not a file: MIX_TEST_PARTITION must be set when the user
        // runs `mix test` in the workspace, and it's safe session-wide because it
        // only takes effect under MIX_ENV=test.
        Ok(Setup {
            resources: vec![resource],
            session_env: vec![("MIX_TEST_PARTITION".to_string(), partition)],
            failed_steps,
            ..Setup::default()
        })
    }
}

/// The `MIX_TEST_PARTITION` value for this workspace. Phoenix's config forms the
/// DB name as `{app}_test{partition}`, so the partition must be identifier-safe
/// and short enough that the whole stays within Postgres's 63-byte limit —
/// otherwise Postgres silently truncates the created name and the (full) name we
/// record for teardown no longer matches it, breaking isolation. workon owns the
/// partition, so capping it keeps the created and recorded names identical.
pub(crate) fn partition_for(app: &str, ws_id: &str) -> String {
    let sanitized: String =
        ws_id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' }).collect();
    let raw = format!("_{sanitized}");
    let budget = 63usize.saturating_sub(app.len() + "_test".len());
    raw[..raw.len().min(budget)].to_string() // sanitized to ASCII, so a byte slice is char-safe
}

/// The OTP app atom from `mix.exs` (`app: :my_app` -> `my_app`). Reads `app:` as
/// a real key: line comments are stripped (an `app:` in a comment must not win)
/// and the match must be at a token boundary (so `myapp:`/`apps_path:` don't
/// count). An umbrella root, which has only `apps_path:`, yields `None` — a
/// graceful no-op.
pub(crate) fn app_name(mix: &str) -> Option<String> {
    for raw in mix.lines() {
        let line = raw.split('#').next().unwrap_or(raw); // drop a trailing/whole-line comment
        for (idx, _) in line.match_indices("app:") {
            // Reject a `app:` glued to a preceding word char (`myapp:`, `apps:`).
            if line[..idx].chars().next_back().is_some_and(|c| c.is_ascii_alphanumeric() || c == '_') {
                continue;
            }
            let atom = line[idx + "app:".len()..].trim_start().trim_start_matches(':');
            let name: String = atom.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
            if !name.is_empty() {
                return Some(name);
            }
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn detects_ecto_sql_in_mix_exs() {
        let tmp = tempfile::tempdir().unwrap();
        assert!(!Phoenix.detect(tmp.path()));
        std::fs::write(tmp.path().join("mix.exs"), "defp deps do [{:phoenix, \"~> 1.7\"}] end").unwrap();
        assert!(!Phoenix.detect(tmp.path()), "phoenix without ecto -> not detected");
        std::fs::write(tmp.path().join("mix.exs"), "defp deps do [{:ecto_sql, \"~> 3.10\"}] end").unwrap();
        assert!(Phoenix.detect(tmp.path()));
    }

    #[test]
    fn partition_keeps_db_name_within_63_bytes() {
        // Short names pass through unchanged (and match the cycle test's DB).
        assert_eq!(partition_for("phx_fixture", "ws-cycle"), "_ws_cycle");
        // A long umbrella app + branch-slug ws_id would overflow 63 bytes.
        let app = "my_umbrella_application_service";
        let ws = "feature-add-new-billing-integration-flow-and-then-some-more";
        let db = format!("{app}_test{}", partition_for(app, ws));
        assert!(db.len() <= 63, "len {}: {db}", db.len());
    }

    #[test]
    fn app_name_reads_the_otp_atom() {
        assert_eq!(app_name("def project do\n[app: :my_app,\n version: \"0.1.0\"]\nend").as_deref(), Some("my_app"));
        assert_eq!(app_name("no app here"), None);
        // A leading comment mentioning app: must not win over the real key.
        assert_eq!(app_name("# set app: :fake here\ndef project do [app: :real] end").as_deref(), Some("real"));
        // Umbrella root (only apps_path:) -> no app -> graceful None.
        assert_eq!(app_name("def project do [apps_path: \"apps\"] end"), None);
        // `app:` glued to a word char isn't the key.
        assert_eq!(app_name("[myapp: :x, app: :good]").as_deref(), Some("good"));
    }

    /// Full cycle against the real fixture: provision it and assert
    /// `mix ecto.create/migrate` created the `widgets` table in the isolated
    /// partitioned DB. Gated on Postgres AND the fixture being fetched/compiled
    /// (deps/ present) AND `mix` on PATH.
    #[test]
    fn phoenix_cycle_creates_and_migrates_isolated_db() {
        use crate::provision::Resource;
        use std::collections::HashMap;
        use std::process::Command;

        if !vcs_runner::binary_available("mix") {
            return;
        }
        let fixture = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/phoenix"));
        if !fixture.join("deps").exists() {
            return; // mix deps.get not run
        }
        let db = "phx_fixture_test_ws_cycle".to_string();
        Resource::PostgresDb { name: db.clone() }.teardown();

        let mise = HashMap::new();
        let ctx = ProvisionCtx {
            project_dir: fixture,
            project_name: "phxfix",
            ws_id: "ws-cycle",
            ws_dir: fixture,
            mise_vars: &mise,
        };
        let setup = Phoenix.setup(&ctx).unwrap();
        // ws_id "ws-cycle" -> partition "_ws_cycle" -> db "phx_fixture_test_ws_cycle".
        assert_eq!(setup.resources, vec![Resource::PostgresDb { name: db.clone() }]);
        // The partition is delivered via the session env (so `mix test` reads it),
        // never a dotenv file.
        assert_eq!(setup.session_env, vec![("MIX_TEST_PARTITION".to_string(), "_ws_cycle".to_string())]);
        assert!(setup.env.is_empty(), "Phoenix uses session_env, not the dotenv file");

        let out = Command::new("psql").args(["-tAc", "select to_regclass('public.widgets')", &db]).output().unwrap();
        let table = String::from_utf8_lossy(&out.stdout).trim().to_string();
        Resource::PostgresDb { name: db.clone() }.teardown();
        assert_eq!(table, "widgets", "ecto.migrate should have created the widgets table");
    }

    proptest::proptest! {
        /// The partition is the ws id with every non-identifier character made `_`, after a
        /// leading `_`, cut so the app's test database name stays within Postgres's 63 bytes.
        #[test]
        fn partition_is_the_sanitized_ws_id_cut_to_fit(app in "[a-z_]{1,70}", ws_id in "\\PC{0,80}") {
            let partition = partition_for(&app, &ws_id);
            let budget = 63usize.saturating_sub(app.len() + "_test".len());
            let whole: String = std::iter::once('_')
                .chain(ws_id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' }))
                .collect();
            proptest::prop_assert_eq!(partition.len(), whole.len().min(budget));
            proptest::prop_assert!(whole.starts_with(&partition), "{partition:?} is not a prefix of {whole:?}");
        }

        /// The app atom reads back past a commented-out `app:` and an `app:` glued to a word.
        #[test]
        fn app_name_reads_the_app_atom_past_comments_and_lookalikes(
            name in "[a-z_][a-z0-9_]{0,15}",
            decoy in "[a-z]{1,6}",
        ) {
            let mix = format!(
                "defmodule X.MixProject do\n  # app: :{decoy}_commented\n  def {decoy}app: :nope\n  def project do\n    [\n      app: :{name},\n      version: \"0.1.0\"\n    ]\n  end\nend\n"
            );
            proptest::prop_assert_eq!(app_name(&mix), Some(name));
        }
    }
}