use assert_cmd::Command;
use predicates::prelude::*;
#[test]
fn flags_add_index_non_concurrent_from_stdin() {
Command::cargo_bin("pgsafe")
.unwrap()
.write_stdin("CREATE INDEX i ON t (x);")
.assert()
.failure()
.code(1)
.stdout(predicate::str::contains("add-index-non-concurrent"));
}
#[test]
fn clean_sql_succeeds() {
Command::cargo_bin("pgsafe")
.unwrap()
.write_stdin("CREATE INDEX CONCURRENTLY i ON t (x);")
.assert()
.success();
}
#[test]
fn invalid_sql_exits_2() {
Command::cargo_bin("pgsafe")
.unwrap()
.write_stdin("ALTER TABLE;")
.assert()
.code(2);
}
#[test]
fn file_input_flags_findings_and_prints_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("migration.sql");
std::fs::write(&path, "CREATE INDEX i ON t (x);").unwrap();
Command::cargo_bin("pgsafe")
.unwrap()
.arg(path.to_str().unwrap())
.assert()
.failure()
.code(1)
.stdout(predicate::str::contains("add-index-non-concurrent"))
.stdout(predicate::str::contains(path.to_str().unwrap()));
}
#[test]
fn file_not_found_exits_2_with_filename_in_stderr() {
Command::cargo_bin("pgsafe")
.unwrap()
.arg("no_such_file.sql")
.assert()
.code(2)
.stderr(predicate::str::contains("no_such_file.sql"));
}
#[test]
fn parse_error_exits_2_with_parse_error_in_stderr() {
Command::cargo_bin("pgsafe")
.unwrap()
.write_stdin("ALTER TABLE;")
.assert()
.code(2)
.stderr(predicate::str::contains("parse error"));
}
#[test]
fn clean_sql_produces_empty_stdout() {
Command::cargo_bin("pgsafe")
.unwrap()
.write_stdin("CREATE INDEX CONCURRENTLY i ON t (x);")
.assert()
.success()
.stdout(predicate::str::is_empty());
}
#[test]
fn multiple_findings_all_appear_in_output() {
let output = Command::cargo_bin("pgsafe")
.unwrap()
.write_stdin("CREATE INDEX a ON t (x); CREATE INDEX b ON t (y);")
.assert()
.failure()
.code(1)
.get_output()
.stdout
.clone();
let stdout = String::from_utf8(output).unwrap();
let count = stdout.matches("add-index-non-concurrent").count();
assert_eq!(count, 2, "expected two findings, got {count} in:\n{stdout}");
}
#[test]
fn json_format_structure_is_correct() {
let out = Command::cargo_bin("pgsafe")
.unwrap()
.args(["--format", "json"])
.write_stdin("CREATE INDEX i ON t (x);")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(1));
let v: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("stdout must be valid JSON");
assert_eq!(v["schema_version"], 2);
assert_eq!(v["files"][0]["file"], "<stdin>");
let fnd = &v["files"][0]["findings"][0];
assert_eq!(fnd["rule_id"], "add-index-non-concurrent");
assert_eq!(fnd["severity"], "error");
assert!(fnd["location"]["line"].is_number(), "line must be a number");
assert!(
fnd["location"]["column"].is_number(),
"column must be a number"
);
assert!(
fnd["snippet"].as_str().unwrap().contains("CREATE INDEX"),
"snippet must contain CREATE INDEX"
);
}
#[test]
fn json_format_clean_sql_exits_0_with_empty_findings() {
let out = Command::cargo_bin("pgsafe")
.unwrap()
.args(["--format", "json"])
.write_stdin("CREATE INDEX CONCURRENTLY i ON t (x);")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0));
let v: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("stdout must be valid JSON");
let findings = v["files"][0]["findings"].as_array().unwrap();
assert!(
findings.is_empty(),
"expected no findings, got {findings:?}"
);
}
#[test]
fn error_severity_renders_in_human_and_json() {
Command::cargo_bin("pgsafe")
.unwrap()
.write_stdin("VACUUM FULL t;")
.assert()
.failure()
.code(1)
.stdout(predicate::str::contains("error [vacuum-full-cluster]"));
let out = Command::cargo_bin("pgsafe")
.unwrap()
.args(["--format", "json"])
.write_stdin("VACUUM FULL t;")
.output()
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["files"][0]["findings"][0]["severity"], "error");
}
#[test]
fn suppressed_only_run_exits_zero() {
Command::cargo_bin("pgsafe")
.unwrap()
.write_stdin(
"SET lock_timeout = '5s';\n-- pgsafe:ignore drop-table empty, confirmed\nDROP TABLE x;",
)
.assert()
.success()
.stdout(predicate::str::contains("suppressed"));
}
#[test]
fn hygiene_diagnostic_gates_exit_one() {
Command::cargo_bin("pgsafe")
.unwrap()
.write_stdin("-- pgsafe:ignore drop-table\nDROP TABLE x;")
.assert()
.failure()
.code(1)
.stdout(predicate::str::contains("suppression-missing-reason"));
}
#[test]
fn json_output_includes_suppression_reason() {
Command::cargo_bin("pgsafe")
.unwrap()
.args(["--format", "json"])
.write_stdin(
"SET lock_timeout = '5s';\n-- pgsafe:ignore drop-table empty, confirmed\nDROP TABLE x;",
)
.assert()
.success()
.stdout(predicate::str::contains("\"suppression\""))
.stdout(predicate::str::contains("empty, confirmed"));
}
#[test]
fn json_format_emits_rule_id_and_file() {
Command::cargo_bin("pgsafe")
.unwrap()
.args(["--format", "json"])
.write_stdin("CREATE INDEX i ON t (x);")
.assert()
.failure()
.code(1)
.stdout(predicate::str::contains("\"add-index-non-concurrent\""))
.stdout(predicate::str::contains("\"file\""));
}
#[test]
fn fail_on_default_gates_on_a_warning() {
Command::cargo_bin("pgsafe")
.unwrap()
.write_stdin("DROP TABLE x;")
.assert()
.failure()
.code(1);
}
#[test]
fn fail_on_error_does_not_gate_on_a_warning() {
Command::cargo_bin("pgsafe")
.unwrap()
.args(["--fail-on", "error"])
.write_stdin("DROP TABLE x;")
.assert()
.success()
.stdout(predicate::str::contains("drop-table")); }
#[test]
fn fail_on_error_gates_on_an_error() {
Command::cargo_bin("pgsafe")
.unwrap()
.args(["--fail-on", "error"])
.write_stdin("VACUUM FULL t;")
.assert()
.failure()
.code(1);
}
#[test]
fn fail_on_never_gates_on_nothing() {
Command::cargo_bin("pgsafe")
.unwrap()
.args(["--fail-on", "never"])
.write_stdin("VACUUM FULL t;")
.assert()
.success()
.stdout(predicate::str::contains("vacuum-full-cluster"));
}
#[test]
fn fail_on_never_still_exits_2_on_parse_error() {
Command::cargo_bin("pgsafe")
.unwrap()
.args(["--fail-on", "never"])
.write_stdin("ALTER TABLE;")
.assert()
.failure()
.code(2);
}
#[test]
fn invalid_fail_on_value_is_a_usage_error() {
Command::cargo_bin("pgsafe")
.unwrap()
.args(["--fail-on", "bogus"])
.write_stdin("DROP TABLE x;")
.assert()
.failure()
.code(2);
}
#[test]
fn in_transaction_flag_flags_top_level_concurrently() {
Command::cargo_bin("pgsafe")
.unwrap()
.write_stdin("CREATE INDEX CONCURRENTLY i ON t (x);")
.assert()
.stdout(predicate::str::contains("concurrently-in-transaction").not());
Command::cargo_bin("pgsafe")
.unwrap()
.args(["--in-transaction"])
.write_stdin("CREATE INDEX CONCURRENTLY i ON t (x);")
.assert()
.failure()
.code(1)
.stdout(predicate::str::contains("concurrently-in-transaction"));
}
#[test]
fn fail_on_error_gates_on_a_hygiene_error_but_not_on_unused() {
Command::cargo_bin("pgsafe")
.unwrap()
.args(["--fail-on", "error"])
.write_stdin("-- pgsafe:ignore drop-table\nDROP TABLE x;")
.assert()
.failure()
.code(1)
.stdout(predicate::str::contains("suppression-missing-reason"));
Command::cargo_bin("pgsafe")
.unwrap()
.args(["--fail-on", "error"])
.write_stdin("-- pgsafe:ignore truncate stale\nDELETE FROM x;")
.assert()
.success()
.stdout(predicate::str::contains("suppression-unused"));
}
#[test]
fn require_primary_key_enabled_via_config_fires() {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("pgsafe.toml");
std::fs::write(&cfg, "[rules]\nrequire-primary-key = true\n").unwrap();
Command::cargo_bin("pgsafe")
.unwrap()
.arg("--no-config")
.write_stdin("CREATE TABLE t (id int);")
.assert()
.success()
.stdout(predicate::str::contains("require-primary-key").not());
Command::cargo_bin("pgsafe")
.unwrap()
.args(["--config", cfg.to_str().unwrap()])
.write_stdin("CREATE TABLE t (id int);")
.assert()
.failure()
.code(1)
.stdout(predicate::str::contains("require-primary-key"));
}
#[test]
fn forbidden_column_type_via_config_fires() {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("pgsafe.toml");
std::fs::write(&cfg, "[forbidden-types]\ntimestamp = \"timestamptz\"\n").unwrap();
Command::cargo_bin("pgsafe")
.unwrap()
.arg("--no-config")
.write_stdin("CREATE TABLE t (created timestamp);")
.assert()
.success()
.stdout(predicate::str::contains("forbidden-column-type").not());
Command::cargo_bin("pgsafe")
.unwrap()
.args(["--config", cfg.to_str().unwrap()])
.write_stdin("CREATE TABLE t (created timestamp);")
.assert()
.failure()
.code(1)
.stdout(predicate::str::contains("forbidden-column-type"));
}
#[test]
fn require_not_null_enabled_via_config_fires() {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("pgsafe.toml");
std::fs::write(&cfg, "[rules]\nrequire-not-null = true\n").unwrap();
Command::cargo_bin("pgsafe")
.unwrap()
.arg("--no-config")
.write_stdin("CREATE TABLE t (email text);")
.assert()
.success()
.stdout(predicate::str::contains("require-not-null").not());
Command::cargo_bin("pgsafe")
.unwrap()
.args(["--config", cfg.to_str().unwrap()])
.write_stdin("CREATE TABLE t (email text);")
.assert()
.failure()
.code(1)
.stdout(predicate::str::contains("require-not-null"));
}
#[test]
fn version_flag_prints_the_crate_version() {
Command::cargo_bin("pgsafe")
.unwrap()
.arg("--version")
.assert()
.success()
.stdout(predicate::str::contains(env!("CARGO_PKG_VERSION")));
}
#[test]
fn require_columns_via_config_fires() {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("pgsafe.toml");
std::fs::write(&cfg, "required-columns = [\"created_at\"]\n").unwrap();
Command::cargo_bin("pgsafe")
.unwrap()
.arg("--no-config")
.write_stdin("CREATE TABLE t (id int);")
.assert()
.success()
.stdout(predicate::str::contains("require-columns").not());
Command::cargo_bin("pgsafe")
.unwrap()
.args(["--config", cfg.to_str().unwrap()])
.write_stdin("CREATE TABLE t (id int);")
.assert()
.failure()
.code(1)
.stdout(predicate::str::contains("require-columns"));
}
#[test]
fn naming_convention_via_config_fires() {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("pgsafe.toml");
std::fs::write(&cfg, "[naming]\ntable = \"^t_\"\n").unwrap();
Command::cargo_bin("pgsafe")
.unwrap()
.arg("--no-config")
.write_stdin("CREATE TABLE users (id int);")
.assert()
.success()
.stdout(predicate::str::contains("naming-convention").not());
Command::cargo_bin("pgsafe")
.unwrap()
.args(["--config", cfg.to_str().unwrap()])
.write_stdin("CREATE TABLE users (id int);")
.assert()
.failure()
.code(1)
.stdout(predicate::str::contains("naming-convention"));
}
#[test]
fn human_default_piped_is_plain_but_has_summary() {
Command::cargo_bin("pgsafe")
.unwrap()
.env_remove("CLICOLOR_FORCE")
.env_remove("NO_COLOR")
.write_stdin("VACUUM FULL t;")
.assert()
.failure()
.code(1)
.stdout(predicate::str::contains(
"Summary: 1 error, 1 warning in 1 file",
))
.stdout(predicate::str::contains('\u{1b}').not())
.stdout(predicate::str::contains('✗').not());
}
#[test]
fn color_always_adds_escapes_and_glyph() {
Command::cargo_bin("pgsafe")
.unwrap()
.env_remove("CLICOLOR_FORCE")
.env_remove("NO_COLOR")
.args(["--color", "always"])
.write_stdin("VACUUM FULL t;")
.assert()
.failure()
.stdout(predicate::str::contains('\u{1b}'))
.stdout(predicate::str::contains('✗'));
}
#[test]
fn color_never_stays_plain() {
Command::cargo_bin("pgsafe")
.unwrap()
.env_remove("CLICOLOR_FORCE")
.env_remove("NO_COLOR")
.args(["--color", "never"])
.write_stdin("VACUUM FULL t;")
.assert()
.failure()
.stdout(predicate::str::contains('\u{1b}').not());
}
#[test]
fn no_color_env_disables_auto_but_always_overrides() {
Command::cargo_bin("pgsafe")
.unwrap()
.env_remove("CLICOLOR_FORCE")
.env("NO_COLOR", "1")
.write_stdin("VACUUM FULL t;")
.assert()
.stdout(predicate::str::contains('\u{1b}').not());
Command::cargo_bin("pgsafe")
.unwrap()
.env_remove("CLICOLOR_FORCE")
.env("NO_COLOR", "1")
.args(["--color", "always"])
.write_stdin("VACUUM FULL t;")
.assert()
.stdout(predicate::str::contains('\u{1b}'));
}
#[test]
fn clicolor_force_colors_piped_output() {
Command::cargo_bin("pgsafe")
.unwrap()
.env_remove("NO_COLOR")
.env("CLICOLOR_FORCE", "1")
.write_stdin("VACUUM FULL t;")
.assert()
.stdout(predicate::str::contains('\u{1b}'));
}
#[test]
fn clicolor_force_beats_no_color_under_auto() {
Command::cargo_bin("pgsafe")
.unwrap()
.env("CLICOLOR_FORCE", "1")
.env("NO_COLOR", "1")
.write_stdin("VACUUM FULL t;")
.assert()
.stdout(predicate::str::contains('\u{1b}'));
}
#[test]
fn clean_run_prints_nothing_to_stdout() {
Command::cargo_bin("pgsafe")
.unwrap()
.env_remove("CLICOLOR_FORCE")
.env_remove("NO_COLOR")
.write_stdin("CREATE INDEX CONCURRENTLY i ON t (x);")
.assert()
.success()
.stdout(predicate::str::is_empty());
}
#[test]
fn color_flag_does_not_affect_json() {
Command::cargo_bin("pgsafe")
.unwrap()
.env_remove("CLICOLOR_FORCE")
.env_remove("NO_COLOR")
.args(["--color", "always", "--format", "json"])
.write_stdin("VACUUM FULL t;")
.assert()
.stdout(predicate::str::contains('\u{1b}').not());
}