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));
}
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();
Ok(Setup {
resources: vec![resource],
session_env: vec![("MIX_TEST_PARTITION".to_string(), partition)],
failed_steps,
..Setup::default()
})
}
}
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() }
pub(crate) fn app_name(mix: &str) -> Option<String> {
for raw in mix.lines() {
let line = raw.split('#').next().unwrap_or(raw); for (idx, _) in line.match_indices("app:") {
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() {
assert_eq!(partition_for("phx_fixture", "ws-cycle"), "_ws_cycle");
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);
assert_eq!(app_name("# set app: :fake here\ndef project do [app: :real] end").as_deref(), Some("real"));
assert_eq!(app_name("def project do [apps_path: \"apps\"] end"), None);
assert_eq!(app_name("[myapp: :x, app: :good]").as_deref(), Some("good"));
}
#[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; }
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();
assert_eq!(setup.resources, vec![Resource::PostgresDb { name: db.clone() }]);
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! {
#[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:?}");
}
#[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));
}
}
}