use std::path::Path;
use anyhow::Result;
use vcs_runner::Cmd;
use super::{run_step, test_db_name, DbEngine, ProvisionCtx, Provisioner, Setup};
pub struct Rails;
impl Provisioner for Rails {
fn name(&self) -> &'static str {
"rails"
}
fn detect(&self, ws_dir: &Path) -> bool {
ws_dir.join("config/database.yml").is_file()
}
fn setup(&self, ctx: &ProvisionCtx<'_>) -> Result<Setup> {
let yml = std::fs::read_to_string(ctx.ws_dir.join("config/database.yml")).unwrap_or_default();
let engine = match adapter(&yml).as_deref() {
Some("postgresql") | None => DbEngine::Postgres,
Some("mysql2") | Some("mysql") | Some("trilogy") => DbEngine::Mysql,
Some("sqlite3") => return Ok(Setup::default()), Some(other) => {
eprintln!("Rails adapter `{other}` not supported; skipping DB setup");
return Ok(Setup::default());
}
};
let db = test_db_name(ctx.project_name, ctx.ws_id);
eprintln!("Creating test database {db}...");
if let Err(e) = engine.create(&db) {
return Ok(Setup::database_not_created(&db, &e));
}
let resource = engine.resource(&db);
let url = engine.url(&db);
let mut failed_steps: Vec<String> = bundle_install(ctx).into_iter().collect();
if failed_steps.is_empty() {
eprintln!("Loading schema...");
let mut cmd = Cmd::new("bundle")
.args(["exec", "rails", "db:schema:load"])
.env("RAILS_ENV", "test")
.env("DATABASE_URL", &url)
.in_dir(ctx.ws_dir);
for (k, v) in ctx.mise_vars {
cmd = cmd.env(k, v);
}
failed_steps.extend(run_step(cmd, "rails db:schema:load"));
}
Ok(Setup::database_url(vec![resource], url, failed_steps))
}
}
fn bundle_install(ctx: &ProvisionCtx<'_>) -> Option<String> {
if !ctx.ws_dir.join("Gemfile").is_file() {
return None;
}
let bundle = |args: &[&str]| {
let mut cmd = Cmd::new("bundle").args(args).in_dir(ctx.ws_dir);
for (k, v) in ctx.mise_vars {
cmd = cmd.env(k, v);
}
cmd
};
if bundle(&["check"]).run().is_ok() {
return None;
}
run_step(bundle(&["install"]), "bundle install")
}
pub(crate) fn adapter(database_yml: &str) -> Option<String> {
database_yml.lines().find_map(|l| {
let v = l.trim().strip_prefix("adapter:")?.trim();
(!v.is_empty()).then(|| v.to_string())
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adapter_reads_first_adapter_line() {
assert_eq!(adapter("default: &default\n adapter: postgresql\n").as_deref(), Some("postgresql"));
assert_eq!(adapter("test:\n adapter: mysql2\n").as_deref(), Some("mysql2"));
assert_eq!(adapter("test:\n database: x\n"), None);
}
#[test]
fn detects_a_rails_app_by_database_yml() {
let tmp = tempfile::tempdir().unwrap();
assert!(!Rails.detect(tmp.path()), "no config/database.yml -> not detected");
std::fs::create_dir(tmp.path().join("config")).unwrap();
std::fs::write(tmp.path().join("config/database.yml"), "test:\n adapter: postgresql\n").unwrap();
assert!(Rails.detect(tmp.path()), "config/database.yml -> detected");
}
#[test]
fn setup_creates_db_and_returns_resource_and_env() {
use crate::provision::Resource;
use std::collections::HashMap;
let name = test_db_name("provtest", "ws-selftest");
Resource::PostgresDb { name: name.clone() }.teardown();
if DbEngine::Postgres.create(&name).is_err() {
return; }
Resource::PostgresDb { name: name.clone() }.teardown();
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("config")).unwrap();
std::fs::write(tmp.path().join("config/database.yml"), "test:\n adapter: postgresql\n").unwrap();
let mise = HashMap::new();
let ctx = ProvisionCtx {
project_dir: tmp.path(),
project_name: "provtest",
ws_id: "ws-selftest",
ws_dir: tmp.path(),
mise_vars: &mise,
};
let setup = Rails.setup(&ctx).unwrap();
Resource::PostgresDb { name: name.clone() }.teardown();
assert_eq!(setup.resources, vec![Resource::PostgresDb { name: name.clone() }]);
assert_eq!(setup.env.len(), 1);
assert_eq!(setup.env[0].0, "DATABASE_URL");
assert!(setup.env[0].1.contains(&name), "url targets the db: {}", setup.env[0].1);
}
#[test]
fn rails_cycle_loads_schema_into_isolated_db() {
use crate::provision::Resource;
use std::collections::HashMap;
use std::process::Command;
let name = test_db_name("railsfix", "ws-cycle");
Resource::PostgresDb { name: name.clone() }.teardown();
if DbEngine::Postgres.create(&name).is_err() {
return; }
Resource::PostgresDb { name: name.clone() }.teardown();
let fixture = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/rails");
let bundle_ready = Command::new("bundle")
.arg("check")
.current_dir(fixture)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !bundle_ready {
return; }
let tmp = tempfile::tempdir().unwrap();
let ws_dir = tmp.path().join("railsfix-ws-cycle");
assert!(
Command::new("cp").args(["-R", fixture, ws_dir.to_str().unwrap()]).status().unwrap().success(),
"copy fixture",
);
let mise = HashMap::new();
let ctx = ProvisionCtx {
project_dir: &ws_dir,
project_name: "railsfix",
ws_id: "ws-cycle",
ws_dir: &ws_dir,
mise_vars: &mise,
};
let setup = Rails.setup(&ctx).unwrap();
assert_eq!(setup.resources, vec![Resource::PostgresDb { name: name.clone() }]);
let out = Command::new("psql").args(["-tAc", "select to_regclass('public.widgets')", &name]).output().unwrap();
let table = String::from_utf8_lossy(&out.stdout).trim().to_string();
Resource::PostgresDb { name: name.clone() }.teardown();
assert_eq!(table, "widgets", "db:schema:load should have created the widgets table");
}
proptest::proptest! {
#[test]
fn adapter_is_the_first_adapter_line_with_a_value(
before in proptest::collection::vec(" (database|host|pool): [a-z0-9_]{0,8}", 0..4),
blank_first in proptest::bool::ANY,
indent in " {0,4}",
value in "[a-z0-9_]{1,12}",
after in proptest::collection::vec(" adapter: [a-z]{1,8}", 0..3),
) {
let blank = if blank_first { " adapter:\n" } else { "" };
let yml = format!("test:\n{}\n{blank}{indent}adapter: {value}\n{}\n", before.join("\n"), after.join("\n"));
proptest::prop_assert_eq!(adapter(&yml), Some(value));
}
}
}