use std::{path::Path, env, process::{Command, Output}, io, fs::{read_dir, create_dir, copy, self}, sync::{Mutex, OnceLock}}; use libsalmo::migration_data::{migrations::MigrationRegistry, committed::{Commit, CommittedFile}}; use log::{debug, LevelFilter, trace}; use postgres::{Client, NoTls}; use rand::{thread_rng, Rng}; use simplelog::{TermLogger, TerminalMode, ColorChoice}; use tempfile::{tempdir, TempDir}; use toml_edit::ser::to_string_pretty; use url::Url; fn db_connstring(dbname: &str, template: &str) -> String { let mut parsed = Url::parse(template).unwrap(); parsed.set_path(dbname); parsed.to_string() } fn template_db(template: &str) -> String { let parsed = Url::parse(template).unwrap(); parsed.path_segments().unwrap().next().unwrap().to_owned() } fn db_name() -> String { let name = (0..16).map(|_| thread_rng().gen_range('a'..='z')).collect::(); format!("test_{}", name) } static TEMPLATE_DB: OnceLock> = OnceLock::new(); impl TempDb { fn from_template(template: String) -> Self { let conn = TEMPLATE_DB.get_or_init(||{ Mutex::new(Client::connect(&template, NoTls).unwrap()) }); let name = db_name(); let create_command = format!("CREATE DATABASE {} TEMPLATE {}", name, template_db(&template)); debug!("executing command \"{}\"", create_command); conn.lock().unwrap().execute(&create_command, &[]).unwrap(); let connstring = db_connstring(&name, &template); Self { db_name: name, connstring, } } pub fn conn(&self) -> Client { Client::connect(&self.connstring, NoTls).unwrap() } } pub struct TempDb { pub connstring: String, db_name: String, } impl Drop for TempDb { fn drop(&mut self) { let drop_cmd = format!("DROP DATABASE {} (FORCE)", self.db_name); debug!("executing command \"{}\"", drop_cmd); TEMPLATE_DB.get().unwrap().lock().unwrap().execute(&drop_cmd, &[]).unwrap(); } } pub struct TestContext { pub dir: TempDir, pub db: TempDb } fn setup_log() { if let Ok(v) = env::var("SALMO_TEST_LOG") { let log_level = match v.as_str() { "true" | "info" => LevelFilter::Info, "debug" => LevelFilter::Debug, "trace" => LevelFilter::Trace, _ => return }; TermLogger::init(log_level, simplelog::Config::default(), TerminalMode::Mixed, ColorChoice::Auto).ok(); // ignore logging setup errors } } fn copy_recursively(from: &Path, to: &Path) { for entry in read_dir(from).unwrap() { let e = entry.unwrap(); let t = e.file_type().unwrap(); let new_to = to.join(e.file_name()); match (t.is_dir(), t.is_file()) { (true, true) => panic!("can't be both dir and file"), (true, false) => { trace!("creating dir {:?}", new_to); create_dir(&new_to).unwrap(); copy_recursively(&e.path(), &new_to); }, (false, true) => { trace!("creating file {:?}", new_to); copy(&e.path(), &new_to).unwrap(); }, (false, false) => panic!("can't be neither dir nor file"), } } debug!("Successfully copied {:?} to {:?}", from, to); } pub fn setup(fixture_dir: &str) -> TestContext { setup_log(); let test_dir = tempdir().unwrap(); let project_dir = Path::new(env!("CARGO_MANIFEST_DIR")); let fixture_dir = project_dir.join("tests/fixtures").join(fixture_dir); copy_recursively(&fixture_dir, test_dir.path()); let db_template_url = env::var("SALMO_TEMPLATE_URL") .unwrap_or_else(|_| format!("postgres://{}@localhost/salmo_test", whoami::username())); let db = TempDb::from_template(db_template_url); TestContext { dir: test_dir, db } } impl TestContext { pub fn new(fixture_dir: &str) -> Self { setup(fixture_dir) } pub fn run(&self, args: &[&str]) -> io::Result { let exe = env!("CARGO_BIN_EXE_salmo"); let should_debug = env::var("SALMO_DEBUG").unwrap_or_default(); let out = Command::new(exe) .current_dir(&self.dir) .env("DATABASE_URL", &self.db.connstring) .env("SALMO_DEBUG", &should_debug) .args(args) .output(); if should_debug == "true" { if let Ok(o) = &out{ if o.status.success() { println!("Successfully executed test command: output = `{}`", std::str::from_utf8(&o.stdout).unwrap()) } else { println!("Error executing test command: output = `{}`", std::str::from_utf8(&o.stderr).unwrap()) } } } out } pub fn commit(&self, migrations: &[&str]) { let r = MigrationRegistry::load(&self.dir.path().join("migrations")).unwrap(); let commits = migrations.iter().map(|m| { Commit { id: m.to_string(), hash: r.db[*m].migrate_hash().unwrap() } }).collect(); let committed_file = to_string_pretty(&CommittedFile { commits }).unwrap(); fs::write(self.dir.path().join("migrations/committed.toml"), committed_file).unwrap() } //intended for non-migration tests as part of setup, since it doesn't return the result pub fn migrate(&self) { self.run(&["migrate"]).unwrap(); } }