use crate::CommandExecute;
use crate::command::get::get_property;
use crate::command::run::Run;
use crate::command::start::collect_postgresql_conf_settings;
use crate::manifest::get_package_manifest;
use owo_colors::OwoColorize;
use pgrx_pg_config::{PgConfig, createdb, dropdb, is_supported_major_version};
use std::collections::HashSet;
use std::env::temp_dir;
use std::fs::{DirEntry, File};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
#[derive(clap::Args, Debug, Clone)]
#[clap(author)]
pub(crate) struct Regress {
#[clap(env = "PG_VERSION")]
pub(crate) args: Vec<String>,
#[clap(skip)]
pub(crate) pg_version: Option<String>,
#[clap(skip)]
pub(crate) test_filter: Option<String>,
#[clap(long)]
pub(crate) dbname: Option<String>,
#[clap(long)]
pub(crate) resetdb: bool,
#[clap(long, short)]
pub(crate) package: Option<String>,
#[clap(long, value_parser)]
pub(crate) manifest_path: Option<PathBuf>,
#[clap(long, short)]
pub(crate) release: bool,
#[clap(long)]
pub(crate) profile: Option<String>,
#[clap(long, short)]
pub(crate) no_schema: bool,
#[clap(long, value_name = "USER")]
pub(crate) runas: Option<String>,
#[clap(long, value_name = "DIR")]
pub(crate) pgdata: Option<PathBuf>,
#[clap(flatten)]
pub(crate) features: clap_cargo::Features,
#[clap(from_global, action = clap::ArgAction::Count)]
pub(crate) verbose: u8,
#[clap(long, value_name = "VERBOSITY")]
pub(crate) psql_verbosity: Option<String>,
#[clap(long)]
pub(crate) postgresql_conf: Vec<String>,
#[clap(long, short)]
pub(crate) auto: bool,
#[clap(long, value_name = "TESTNAME")]
pub(crate) add: Option<String>,
#[clap(long)]
pub(crate) dry_run: bool,
#[clap(long, default_value_t = 1, value_name = "N")]
pub(crate) repeat: u32,
}
impl Regress {
#[rustfmt::skip]
fn is_setup_sql_newer(&self, manifest_path: &Path) -> bool {
if self.resetdb {
return true;
}
let sql = manifest_path_to_sql_tests_path(manifest_path);
if !sql.exists() { return false; }
let expected = manifest_path_to_expected_tests_output_path(manifest_path);
if !expected.exists() {return false; }
let setup_sql = sql.join("setup.sql");
let setup_out = expected.join("setup.out");
let Ok(setup_sql) = std::fs::metadata(setup_sql) else { return false; };
let Ok(setup_out) = std::fs::metadata(setup_out) else { return true; };
let Ok(sql_modified) = setup_sql.modified() else { return false; };
let Ok(out_modified) = setup_out.modified() else { return false; };
sql_modified > out_modified
}
fn list_sql_tests(
&self,
manifest_path: &Path,
include_setup: bool,
) -> eyre::Result<Vec<DirEntry>> {
let sql = manifest_path_to_sql_tests_path(manifest_path);
if !sql.exists() {
std::fs::create_dir(&sql)?;
}
let mut files = std::fs::read_dir(sql)?.collect::<Result<Vec<_>, _>>()?;
Self::organize_files(&mut files, "sql", include_setup);
Ok(files)
}
fn list_expected_outputs(
&self,
manifest_path: &Path,
include_setup: bool,
) -> eyre::Result<Vec<DirEntry>> {
let expected = manifest_path_to_expected_tests_output_path(manifest_path);
if !expected.exists() {
std::fs::create_dir(&expected)?;
}
let mut files = std::fs::read_dir(expected)?.collect::<Result<Vec<_>, _>>()?;
Self::organize_files(&mut files, "out", include_setup);
Ok(files)
}
fn list_results_outputs(
&self,
manifest_path: &Path,
include_setup: bool,
) -> eyre::Result<Vec<DirEntry>> {
let results = manifest_path_to_results_output_path(manifest_path);
if !results.exists() {
std::fs::create_dir(&results)?;
}
let mut files = std::fs::read_dir(results)?.collect::<Result<Vec<_>, _>>()?;
Self::organize_files(&mut files, "out", include_setup);
Ok(files)
}
fn organize_files(files: &mut Vec<DirEntry>, only: &str, include_setup: bool) {
files.retain(|entry| {
entry
.metadata()
.map(|metadata| {
metadata.is_file()
&& entry
.file_name()
.to_str()
.map(|filename| filename.ends_with(&format!(".{only}")))
.unwrap_or_default()
})
.unwrap_or_default()
});
let is_setup = |entry: &DirEntry| {
entry
.file_name()
.to_str()
.is_some_and(|filename| filename.ends_with(&format!("setup.{only}")))
};
let setup_entry = files.iter().position(is_setup).map(|idx| files.remove(idx));
files.sort_unstable_by_key(|entry| entry.file_name());
if let Some(setup_entry) = setup_entry
&& include_setup
{
files.insert(0, setup_entry);
}
}
fn bootstrap_new_test(
&self,
pg_config: &PgConfig,
manifest_path: &Path,
pgregress_path: &Path,
dbname: &str,
test_file: &DirEntry,
) -> eyre::Result<()> {
let test_name = make_test_name(test_file);
let verbosity = &self.psql_verbosity.clone().unwrap_or("terse".into());
println!("{} new test `{}`", "Bootstrapping".bold().green(), test_name.bold().cyan());
if let Some(test_result_output) = create_regress_output(
pg_config,
manifest_path,
pgregress_path,
dbname,
test_file,
verbosity,
)? {
let expected_path = manifest_path_to_expected_tests_output_path(manifest_path)
.join(format!("{test_name}.out"));
println!(
"{} test output to {}",
" Copying".bold().green(),
expected_path.display().bold().cyan()
);
std::fs::copy(&test_result_output, &expected_path)?;
add_to_git(&expected_path)?;
}
Ok(())
}
fn run_all_tests(
&self,
pg_config: &PgConfig,
manifest_path: &Path,
pgregress_path: &Path,
dbname: &str,
test_files: &[&DirEntry],
output_files: &[&DirEntry],
include_setup: bool,
run: u32,
) -> eyre::Result<bool> {
let output_names = output_files.iter().map(|e| make_test_name(e)).collect::<HashSet<_>>();
let (ready_tests, new_tests): (Vec<&&DirEntry>, Vec<&&DirEntry>) =
test_files.iter().partition(|entry| output_names.contains(&make_test_name(entry)));
let ready_tests: Vec<&DirEntry> = ready_tests.into_iter().copied().collect();
for new_test in &new_tests {
let name = make_test_name(new_test);
println!(
"{} {} (use {} to bootstrap)",
"SKIP".bold().yellow(),
name,
"--add".bold().white()
);
}
let skipped_cnt = new_tests.len();
if ready_tests.is_empty() {
println!("passed=0 failed=0 skipped={skipped_cnt}");
return Ok(true);
}
let verbosity = &self.psql_verbosity.clone().unwrap_or("terse".into());
let success =
run_tests(pg_config, pgregress_path, dbname, &ready_tests, verbosity, skipped_cnt)?;
if !success {
print_regression_diffs(manifest_path, self.verbose, run, self.repeat);
if self.auto {
let results_files = self.list_results_outputs(manifest_path, include_setup)?;
println!();
for entry in results_files {
let filename = entry
.file_name()
.to_str()
.expect("filename should be valid UTF8")
.to_owned();
let expected_path =
manifest_path_to_expected_tests_output_path(manifest_path).join(filename);
if !expected_path.exists() {
continue;
}
let src = std::fs::read_to_string(entry.path())?;
let dst = std::fs::read_to_string(&expected_path)?;
if src != dst {
println!(
"{} {}'s output to {}",
" Promoting".bold().yellow(),
make_test_name(&entry).bold().bright_red(),
expected_path.display().bold().cyan()
);
std::fs::copy(entry.path(), &expected_path)?;
}
}
}
}
Ok(success)
}
}
impl CommandExecute for Regress {
#[tracing::instrument(level = "error", skip(self))]
fn execute(mut self) -> eyre::Result<()> {
unsafe {
std::env::set_var("PGRX_REGRESS_TESTING", "1");
}
self.resolve_args();
if self.add.is_some() {
self.resetdb = true;
}
let (_, manifest_path) = get_package_manifest(
&self.features,
self.package.as_deref(),
self.manifest_path.as_deref(),
)?;
let extname = get_property(&manifest_path, "extname")?
.expect("extension name property `extname` should always be known");
self.dbname = Some(self.dbname.unwrap_or_else(|| format!("{extname}_regress")));
self.postgresql_conf.push("client_min_messages=warning".into());
let postgresql_conf = collect_postgresql_conf_settings(&self.postgresql_conf)?;
if self.dry_run {
return self.execute_dry_run(&manifest_path);
}
let (pg_config, dbname) = Run::from(&self).install(false, &postgresql_conf)?;
let pgregress_path = pg_config.pg_regress_path()?;
let mut any_failed = false;
for run in 1..=self.repeat {
if self.repeat > 1 {
println!();
println!("=== run {run} of {} ===", self.repeat);
}
if self.is_setup_sql_newer(&manifest_path) {
println!(
"{} database {} to be (re)created as `setup.sql` is newer than its expected output",
" Forcing".bold().yellow(),
dbname.cyan()
);
}
if self.resetdb || self.is_setup_sql_newer(&manifest_path) {
dropdb(&pg_config, &dbname, false, self.runas.clone())?;
}
let created_db = createdb(&pg_config, &dbname, false, true, self.runas.clone())?;
if !created_db {
println!("{} existing database {dbname}", " Re-using".bold().cyan());
}
if let Some(ref add_name) = self.add {
return self.execute_add(
&pg_config,
&manifest_path,
&pgregress_path,
&dbname,
add_name,
created_db,
);
}
let mut test_files = self.list_sql_tests(&manifest_path, created_db)?;
let output_files = self.list_expected_outputs(&manifest_path, created_db)?;
if let Some(test_filter) = self.test_filter.as_ref() {
test_files.retain(|entry| {
let name = make_test_name(entry);
(created_db && name == "setup") || name.contains(test_filter)
});
if test_files.is_empty() {
println!(
"{} no tests matching filter `{test_filter}`",
" ERROR".bold().red()
);
std::process::exit(1);
}
let output_names =
output_files.iter().map(|e| make_test_name(e)).collect::<HashSet<_>>();
for entry in &test_files {
let name = make_test_name(entry);
if !output_names.contains(&name) {
eprintln!(
"{} test `{}` has no expected output. Run `cargo pgrx regress --add {}` first.",
" ERROR".bold().red(),
name.bold().cyan(),
name,
);
std::process::exit(1);
}
}
}
println!();
println!("--- beginning regression test run ---");
let success = self.run_all_tests(
&pg_config,
&manifest_path,
&pgregress_path,
&dbname,
&test_files.iter().collect::<Vec<_>>(),
&output_files.iter().collect::<Vec<_>>(),
created_db, run,
)?;
if !success {
any_failed = true;
}
}
if any_failed {
std::process::exit(1);
}
Ok(())
}
}
impl Regress {
fn resolve_args(&mut self) {
match Self::parse_args(&self.args) {
Ok((pg_version, test_filter)) => {
self.pg_version = pg_version;
self.test_filter = test_filter;
}
Err(message) => {
eprintln!("{} {message}", " ERROR".bold().red());
std::process::exit(1);
}
}
}
fn parse_args(args: &[String]) -> Result<(Option<String>, Option<String>), String> {
fn is_supported_pg_version_label(label: &str) -> bool {
label
.strip_prefix("pg")
.and_then(|major| major.parse::<u16>().ok())
.is_some_and(is_supported_major_version)
}
match args {
[] => Ok((None, None)),
[only] if is_supported_pg_version_label(only) => Ok((Some(only.clone()), None)),
[only] => Ok((None, Some(only.clone()))),
[first, second] if is_supported_pg_version_label(first) => {
Ok((Some(first.clone()), Some(second.clone())))
}
[first, _second] => Err(format!(
"first positional argument must be a PostgreSQL version (e.g., pg16), got `{first}`"
)),
_ => {
Err("too many positional arguments. Usage: cargo pgrx regress [pgXX] [testname]"
.into())
}
}
}
fn execute_add(
&self,
pg_config: &PgConfig,
manifest_path: &Path,
pgregress_path: &Path,
dbname: &str,
add_name: &str,
created_db: bool,
) -> eyre::Result<()> {
let test_files = self.list_sql_tests(manifest_path, created_db)?;
let expected_outputs = self.list_expected_outputs(manifest_path, created_db)?;
let test_file = test_files.iter().find(|entry| make_test_name(entry) == add_name);
let Some(test_file) = test_file else {
eprintln!(
"{} no test file `{}.sql` found in {}",
" ERROR".bold().red(),
add_name.bold().cyan(),
manifest_path_to_sql_tests_path(manifest_path).display(),
);
std::process::exit(1);
};
if expected_outputs.iter().any(|e| make_test_name(e) == add_name) {
eprintln!(
"{} test `{}` already has expected output. Use `cargo pgrx regress` to run it, \
or `cargo pgrx regress --auto` to update its expected output.",
" WARNING".bold().yellow(),
add_name.bold().cyan(),
);
std::process::exit(1);
}
let setup_files = self.list_sql_tests(manifest_path, true)?;
let setup_entry = setup_files.iter().find(|e| make_test_name(e) == "setup");
if let Some(setup_entry) = setup_entry {
let setup_has_output = expected_outputs.iter().any(|e| make_test_name(e) == "setup");
if !setup_has_output {
println!("{} setup.sql (no expected output yet)", "Bootstrapping".bold().green(),);
self.bootstrap_new_test(
pg_config,
manifest_path,
pgregress_path,
dbname,
setup_entry,
)?;
} else {
let verbosity = &self.psql_verbosity.clone().unwrap_or("terse".into());
run_tests(pg_config, pgregress_path, dbname, &[&setup_entry], verbosity, 0)?;
}
}
self.bootstrap_new_test(pg_config, manifest_path, pgregress_path, dbname, test_file)?;
println!(
"\n{} test `{}` bootstrapped. Run `cargo pgrx regress` to verify.",
" Done".bold().green(),
add_name.bold().cyan(),
);
Ok(())
}
fn execute_dry_run(&self, manifest_path: &Path) -> eyre::Result<()> {
let extname = get_property(manifest_path, "extname")?
.expect("extension name property `extname` should always be known");
let dbname = self.dbname.clone().unwrap_or_else(|| format!("{extname}_regress"));
let profile = if self.release { "release" } else { "dev" };
println!("{}", "--- dry run ---".bold().cyan());
println!(
"Would build and install extension '{}' ({} profile)",
extname.bold().white(),
profile,
);
if let Some(ref pg_version) = self.pg_version {
println!("Target Postgres version: {}", pg_version.bold().white());
} else {
println!("Target Postgres version: (from Cargo.toml default features)");
}
if self.resetdb || self.add.is_some() {
println!("Would {} database '{}'", "drop and recreate".bold().yellow(), dbname.cyan(),);
} else {
println!("Would reuse existing database '{}'", dbname.cyan());
}
if let Some(ref add_name) = self.add {
let sql_path =
manifest_path_to_sql_tests_path(manifest_path).join(format!("{add_name}.sql"));
if sql_path.exists() {
println!("Would bootstrap new test: {}", add_name.bold().green());
} else {
println!("{} no test file `{}.sql` found", " ERROR".bold().red(), add_name,);
}
return Ok(());
}
let test_files = self.list_sql_tests(manifest_path, false)?;
let output_files = self.list_expected_outputs(manifest_path, false)?;
let output_names = output_files.iter().map(|e| make_test_name(e)).collect::<HashSet<_>>();
let mut ready = Vec::new();
let mut skipped = Vec::new();
for entry in &test_files {
let name = make_test_name(entry);
if let Some(ref filter) = self.test_filter {
if !name.contains(filter) {
continue;
}
}
if output_names.contains(&name) {
ready.push(name);
} else {
skipped.push(name);
}
}
if !ready.is_empty() {
println!("Would run {} tests: {}", ready.len(), ready.join(", "),);
}
if !skipped.is_empty() {
println!(
"Would skip {} tests without expected output: {}",
skipped.len(),
skipped.join(", "),
);
}
if self.auto {
println!("Would {} failed test output to expected/", "promote".bold().yellow());
}
Ok(())
}
}
fn run_tests(
pg_config: &PgConfig,
pg_regress_bin: &Path,
dbname: &str,
test_files: &[&DirEntry],
verbosity: &str,
skipped_cnt: usize,
) -> eyre::Result<bool> {
if test_files.is_empty() {
return Ok(true);
}
let input_dir = test_files[0].path();
let input_dir = input_dir
.parent()
.expect("test file should not be at the root of the filesystem")
.parent()
.expect("test file should be in a directory named `sql/`")
.to_path_buf();
pg_regress(pg_config, pg_regress_bin, dbname, &input_dir, test_files, verbosity, skipped_cnt)
.map(|status| status.success())
}
fn create_regress_output(
pg_config: &PgConfig,
manifest_path: &Path,
pg_regress_bin: &Path,
dbname: &str,
test_file: &DirEntry,
verbosity: &str,
) -> eyre::Result<Option<PathBuf>> {
let test_name = make_test_name(test_file);
let input_dir = test_file.path();
let input_dir = input_dir
.parent()
.expect("test file should not be at the root of the filesystem")
.parent()
.expect("test file should be in a directory named `sql/`")
.to_path_buf();
let status =
pg_regress(pg_config, pg_regress_bin, dbname, &input_dir, &[test_file], verbosity, 0)?;
if !status.success() {
let out_file =
manifest_path_to_results_output_path(manifest_path).join(format!("{test_name}.out"));
if out_file.exists() {
return Ok(Some(out_file));
} else {
std::process::exit(status.code().unwrap_or(1));
}
}
Ok(None)
}
fn pg_regress(
pg_config: &PgConfig,
bin: &Path,
dbname: &str,
input_dir: &Path,
tests: &[&DirEntry],
verbosity: &str,
skipped_cnt: usize,
) -> eyre::Result<ExitStatus> {
if tests.is_empty() {
eyre::bail!("no tests to run");
}
let test_dir = tests[0].path().parent().unwrap().parent().unwrap().to_path_buf();
let tests = tests.iter().map(|entry| make_test_name(entry));
let mut command = Command::new(bin);
command
.current_dir(test_dir)
.env_remove("PGDATABASE")
.env_remove("PGHOST")
.env_remove("PGPORT")
.env_remove("PGUSER")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.arg("--host")
.arg(pg_config.host())
.arg("--port")
.arg(pg_config.port()?.to_string())
.arg("--use-existing")
.arg(format!("--dbname={dbname}"))
.arg(format!("--inputdir={}", input_dir.display()))
.arg(format!("--outputdir={}", input_dir.display()))
.args(tests);
#[cfg(not(target_os = "windows"))]
let launcher_script = {
fn make_launcher_script(verbosity: &str) -> eyre::Result<PathBuf> {
use std::os::unix::fs::PermissionsExt;
let launcher_script =
format!("#! /bin/bash\n$* -v VERBOSITY={}", verbosity.to_string(),).into_bytes();
let path = temp_dir().join(format!("pgrx-pg_regress-runner-{}.sh", std::process::id()));
let mut tmpfile = File::create(&path)?;
tmpfile.write_all(&launcher_script)?;
let mut perms = path.metadata()?.permissions();
perms.set_mode(0o700);
tmpfile.set_permissions(perms)?;
Ok(path)
}
let launcher_script = make_launcher_script(verbosity)?;
command.arg(format!("--launcher={}", launcher_script.display()));
launcher_script
};
tracing::trace!("running {command:?}");
let mut child = command.spawn()?;
let (Some(stdout), Some(stderr)) = (child.stdout.take(), child.stderr.take()) else {
panic!("unable to take stdout or stderr from pg_regress process");
};
let output_monitor = std::thread::spawn(move || {
let mut passed_cnt = 0;
let mut failed_cnt = 0;
let stdout = BufReader::new(stdout);
let stderr = BufReader::new(stderr);
for line in stdout.lines().chain(stderr.lines()) {
let line = line.unwrap();
let Some((line, result)) = decorate_output(line) else {
continue;
};
match result {
Some(TestResult::Passed) => passed_cnt += 1,
Some(TestResult::Failed) => failed_cnt += 1,
None => (),
}
println!("{line}");
}
(passed_cnt, failed_cnt)
});
let status = child.wait()?;
let (passed_cnt, failed_cnt) =
output_monitor.join().map_err(|_| eyre::eyre!("failed to join output monitor thread"))?;
if skipped_cnt > 0 {
println!("passed={passed_cnt} failed={failed_cnt} skipped={skipped_cnt}");
} else {
println!("passed={passed_cnt} failed={failed_cnt}");
}
#[cfg(not(target_os = "windows"))]
{
std::fs::remove_file(launcher_script)?;
}
Ok(status)
}
fn print_regression_diffs(manifest_path: &Path, verbose: u8, run: u32, repeat: u32) {
let diffs_path = manifest_path_to_pg_regress_dir(manifest_path).join("regression.diffs");
if !diffs_path.exists() {
return;
}
if verbose >= 1 {
if let Ok(content) = std::fs::read_to_string(&diffs_path) {
eprintln!();
eprintln!("{content}");
}
}
let final_path = if repeat > 1 {
let renamed =
manifest_path_to_pg_regress_dir(manifest_path).join(format!("regression.{run}.diffs"));
let _ = std::fs::remove_file(&renamed);
if let Err(e) = std::fs::rename(&diffs_path, &renamed) {
eprintln!(
"{} failed to rename regression.diffs to {}: {e}",
" WARNING".bold().yellow(),
renamed.display()
);
diffs_path
} else {
renamed
}
} else {
diffs_path
};
eprintln!("\n{} {}", " Diffs at".bold().red(), final_path.display().bold().cyan());
}
enum TestResult {
Passed,
Failed,
}
fn decorate_output(mut line: String) -> Option<(String, Option<TestResult>)> {
let mut decorated = String::with_capacity(line.len());
let mut test_result: Option<TestResult> = None;
let mut is_old_line = false;
let mut is_new_line = false;
if line.starts_with("ok") {
is_new_line = true;
} else if line.starts_with("not ok") {
line = line.replace("not ok", "not_ok"); is_new_line = true;
} else if line.contains("... ok") || line.contains("... FAILED") {
is_old_line = true;
}
let parsed_test_line = if is_new_line {
fn split_line(line: &str) -> Option<(&str, bool, &str, &str)> {
let mut parts = line.split_whitespace();
let passed = parts.next()? == "ok";
parts.next()?; parts.next()?; let test_name = parts.next()?;
let execution_time = parts.next()?;
let execution_units = parts.next()?;
Some((test_name, passed, execution_time, execution_units))
}
split_line(&line)
} else if is_old_line {
fn split_line(line: &str) -> Option<(&str, bool, &str, &str)> {
let mut parts = line.split_whitespace();
parts.next()?; let test_name = parts.next()?;
parts.next()?; let passed = parts.next()? == "ok";
let execution_time = parts.next()?;
let execution_units = parts.next()?;
Some((test_name, passed, execution_time, execution_units))
}
split_line(&line)
} else {
return None;
};
if let Some((test_name, passed, execution_time, execution_units)) = parsed_test_line {
if passed {
test_result = Some(TestResult::Passed);
} else {
test_result = Some(TestResult::Failed);
}
decorated.push_str(&format!(
"{} {test_name} {execution_time}{execution_units}",
if passed {
"PASS".bold().bright_green().to_string()
} else {
"FAIL".bold().bright_red().to_string()
}
))
}
Some((decorated, test_result))
}
fn make_test_name(entry: &DirEntry) -> String {
let filename = entry.file_name();
let filename = filename.to_str().unwrap_or_else(|| panic!("bogus file name: {entry:?}"));
let filename =
filename.split('.').next().unwrap_or_else(|| panic!("invalid filename: `{filename}`"));
filename.to_string()
}
fn manifest_path_to_sql_tests_path(manifest_path: &Path) -> PathBuf {
let mut path = PathBuf::from(manifest_path);
path.pop(); path.push("tests");
path.push("pg_regress");
path.push("sql");
path
}
fn manifest_path_to_expected_tests_output_path(manifest_path: &Path) -> PathBuf {
let mut path = PathBuf::from(manifest_path);
path.pop(); path.push("tests");
path.push("pg_regress");
path.push("expected");
path
}
fn manifest_path_to_results_output_path(manifest_path: &Path) -> PathBuf {
let mut path = PathBuf::from(manifest_path);
path.pop(); path.push("tests");
path.push("pg_regress");
path.push("results");
path
}
fn manifest_path_to_pg_regress_dir(manifest_path: &Path) -> PathBuf {
let mut path = PathBuf::from(manifest_path);
path.pop(); path.push("tests");
path.push("pg_regress");
path
}
fn add_to_git(path: &Path) -> eyre::Result<()> {
if let Ok(git) = which::which("git")
&& is_git_repo(&git)
&& !Command::new(git).arg("add").arg(path).status()?.success()
{
panic!("unable to add {} to git", path.display());
}
Ok(())
}
fn is_git_repo(git: &Path) -> bool {
Command::new(git)
.stdout(Stdio::null())
.stderr(Stdio::null())
.arg("rev-parse")
.arg("--is-inside-work-tree")
.status()
.map(|status| status.success())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::Regress;
fn strings(args: &[&str]) -> Vec<String> {
args.iter().map(|arg| (*arg).to_string()).collect()
}
#[test]
fn parse_args_treats_single_non_version_as_test_filter() {
let (pg_version, test_filter) = Regress::parse_args(&strings(&["cursor_coverage"]))
.expect("single test name should parse");
assert_eq!(pg_version, None);
assert_eq!(test_filter.as_deref(), Some("cursor_coverage"));
}
#[test]
fn parse_args_accepts_pg_version_then_test_filter() {
let (pg_version, test_filter) = Regress::parse_args(&strings(&["pg16", "cursor_coverage"]))
.expect("pg version plus test filter should parse");
assert_eq!(pg_version.as_deref(), Some("pg16"));
assert_eq!(test_filter.as_deref(), Some("cursor_coverage"));
}
#[test]
fn parse_args_rejects_test_filter_then_pg_version() {
let err = Regress::parse_args(&strings(&["cursor_coverage", "pg16"]))
.expect_err("reversed positional order should still fail");
assert_eq!(
err,
"first positional argument must be a PostgreSQL version (e.g., pg16), got `cursor_coverage`"
);
}
#[test]
fn parse_args_does_not_treat_unsupported_pg_label_as_version() {
let (pg_version, test_filter) = Regress::parse_args(&strings(&["pg99"]))
.expect("unsupported pg label should fall back to test filter");
assert_eq!(pg_version, None);
assert_eq!(test_filter.as_deref(), Some("pg99"));
}
}