use assert_cmd::Command;
use predicates::prelude::*;
use std::fs;
use std::process::Command as StdCommand;
use tempfile::tempdir;
fn pgsafe() -> Command {
Command::cargo_bin("pgsafe").unwrap()
}
fn git_available() -> bool {
StdCommand::new("git")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[test]
fn fix_rewrites_file_in_place() {
let dir = tempdir().unwrap();
let f = dir.path().join("m.sql");
fs::write(&f, "CREATE INDEX i ON t (c);\n").unwrap();
pgsafe().arg("--fix").arg(&f).assert().success();
let after = fs::read_to_string(&f).unwrap();
assert!(after.contains("CONCURRENTLY"), "got: {after}");
}
#[test]
fn fix_stdin_writes_fixed_sql_to_stdout() {
pgsafe()
.arg("--fix")
.write_stdin("ALTER TABLE t ADD COLUMN c json;\n")
.assert()
.success()
.stdout(predicate::str::contains("jsonb"));
}
#[test]
fn diff_previews_without_writing() {
let dir = tempdir().unwrap();
let f = dir.path().join("m.sql");
let before = "CREATE INDEX i ON t (c);\n";
fs::write(&f, before).unwrap();
pgsafe()
.arg("--diff")
.arg(&f)
.assert()
.failure()
.code(1)
.stdout(predicate::str::contains("+CREATE INDEX CONCURRENTLY"));
assert_eq!(
fs::read_to_string(&f).unwrap(),
before,
"diff must not write"
);
}
#[test]
fn fix_is_idempotent() {
let dir = tempdir().unwrap();
let f = dir.path().join("m.sql");
fs::write(&f, "ALTER TABLE t ADD COLUMN c json;\n").unwrap();
let _ = pgsafe().arg("--fix").arg(&f).assert();
let once = fs::read_to_string(&f).unwrap();
let _ = pgsafe().arg("--fix").arg(&f).assert();
let twice = fs::read_to_string(&f).unwrap();
assert_eq!(once, twice, "second --fix must be a no-op");
}
#[test]
fn fix_conflicts_with_diff() {
pgsafe()
.arg("--fix")
.arg("--diff")
.write_stdin("SELECT 1;")
.assert()
.failure()
.code(2);
}
#[test]
fn fix_conflicts_with_json_format() {
pgsafe()
.args(["--fix", "--format", "json"])
.write_stdin("SELECT 1;")
.assert()
.failure()
.code(2);
}
#[test]
fn fix_does_not_touch_suppressed_findings() {
let dir = tempdir().unwrap();
let f = dir.path().join("m.sql");
let src =
"-- pgsafe:ignore add-index-non-concurrent reviewed, acceptable here\nCREATE INDEX i ON t (c);\n";
fs::write(&f, src).unwrap();
let _ = pgsafe().arg("--fix").arg(&f).assert();
let after = fs::read_to_string(&f).unwrap();
assert!(
!after.contains("CONCURRENTLY"),
"suppressed finding must not be fixed: {after}"
);
}
#[test]
fn fix_exit_reflects_post_fix_gate() {
let dir = tempdir().unwrap();
let f = dir.path().join("clean.sql");
fs::write(&f, "ALTER TABLE t ADD COLUMN c json;\n").unwrap();
pgsafe().arg("--fix").arg(&f).assert().success();
}
#[test]
fn fix_conflicts_with_github_format() {
pgsafe()
.args(["--fix", "--format", "github"])
.write_stdin("SELECT 1;")
.assert()
.failure()
.code(2);
}
#[test]
fn fix_partial_leaves_unfixable_and_exits_1() {
let dir = tempdir().unwrap();
let f = dir.path().join("partial.sql");
fs::write(&f, "CREATE INDEX i ON t (c);\nDROP TABLE old_stuff;\n").unwrap();
pgsafe().arg("--fix").arg(&f).assert().failure().code(1);
let after = fs::read_to_string(&f).unwrap();
assert!(
after.contains("CREATE INDEX CONCURRENTLY i ON t (c);"),
"fixable finding should be applied: {after}"
);
assert!(
after.contains("DROP TABLE old_stuff;"),
"unfixable statement must be left unchanged: {after}"
);
}
#[test]
fn fix_summary_reports_counts() {
let dir = tempdir().unwrap();
let f = dir.path().join("partial.sql");
fs::write(&f, "CREATE INDEX i ON t (c);\nDROP TABLE old_stuff;\n").unwrap();
pgsafe()
.arg("--fix")
.arg(&f)
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("fixed 2 findings"))
.stderr(predicate::str::contains("unfixable"));
}
#[test]
fn fix_composes_multiple_fixes_in_one_file() {
let dir = tempdir().unwrap();
let f = dir.path().join("multi.sql");
fs::write(&f, "CREATE INDEX i ON a (c);\nCREATE INDEX j ON b (c);\n").unwrap();
pgsafe().arg("--fix").arg(&f).assert().success();
let after = fs::read_to_string(&f).unwrap();
assert!(
after.contains("CREATE INDEX CONCURRENTLY i ON a (c);"),
"first index not fixed: {after}"
);
assert!(
after.contains("CREATE INDEX CONCURRENTLY j ON b (c);"),
"second index not fixed: {after}"
);
}
#[test]
fn diff_exit_zero_on_clean_file() {
let dir = tempdir().unwrap();
let f = dir.path().join("clean.sql");
fs::write(&f, "SELECT 1;\n").unwrap();
pgsafe()
.arg("--diff")
.arg(&f)
.assert()
.success()
.stdout(predicate::str::is_empty());
}
#[test]
fn diff_unfixable_only_not_silent() {
let dir = tempdir().unwrap();
let f = dir.path().join("rename.sql");
fs::write(&f, "ALTER TABLE old_name RENAME TO new_name;\n").unwrap();
pgsafe()
.arg("--diff")
.arg(&f)
.assert()
.failure()
.code(1)
.stdout(predicate::str::is_empty())
.stderr(predicate::str::contains("have no automatic fix"));
}
#[test]
fn diff_matches_fix_output() {
let dir = tempdir().unwrap();
let df = dir.path().join("diff.sql");
let ff = dir.path().join("fix.sql");
let src = "CREATE INDEX i ON a (c);\nCREATE INDEX j ON b (c);\n";
fs::write(&df, src).unwrap();
fs::write(&ff, src).unwrap();
let diff_out = pgsafe().arg("--diff").arg(&df).assert();
let diff_stdout = String::from_utf8(diff_out.get_output().stdout.clone()).unwrap();
let plus_lines: String = diff_stdout
.lines()
.filter(|l| l.starts_with('+') && !l.starts_with("+++"))
.map(|l| format!("{}\n", &l[1..]))
.collect();
let _ = pgsafe().arg("--fix").arg(&ff).assert();
let fixed = fs::read_to_string(&ff).unwrap();
assert_eq!(
plus_lines, fixed,
"diff `+` lines must reconstruct the --fix output"
);
}
#[cfg(unix)]
#[test]
fn fix_write_error_exits_2() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let f = dir.path().join("ro.sql");
fs::write(&f, "CREATE INDEX i ON t (c);\n").unwrap();
fs::set_permissions(&f, fs::Permissions::from_mode(0o444)).unwrap();
pgsafe()
.arg("--fix")
.arg(&f)
.assert()
.failure()
.code(2)
.stderr(predicate::str::contains(f.to_str().unwrap()));
}
#[test]
fn diff_output_applies_cleanly_with_git_apply() {
if !git_available() {
eprintln!("skipping: git not available");
return;
}
let dir = tempdir().unwrap();
assert!(StdCommand::new("git")
.arg("init")
.current_dir(dir.path())
.output()
.unwrap()
.status
.success());
let rel = "m.sql";
let original = "CREATE INDEX i ON t (x);\nSELECT 1;\nDROP TABLE stale;\n";
fs::write(dir.path().join(rel), original).unwrap();
let diff = pgsafe()
.arg("--diff")
.arg(rel)
.current_dir(dir.path())
.assert();
let patch = String::from_utf8(diff.get_output().stdout.clone()).unwrap();
fs::write(dir.path().join("fix.patch"), &patch).unwrap();
let check = StdCommand::new("git")
.args(["apply", "--check", "fix.patch"])
.current_dir(dir.path())
.output()
.unwrap();
assert!(
check.status.success(),
"git apply --check failed:\n{}\n---patch---\n{patch}",
String::from_utf8_lossy(&check.stderr)
);
assert!(StdCommand::new("git")
.args(["apply", "fix.patch"])
.current_dir(dir.path())
.output()
.unwrap()
.status
.success());
let applied_via_diff = fs::read_to_string(dir.path().join(rel)).unwrap();
let dir2 = tempdir().unwrap();
fs::write(dir2.path().join(rel), original).unwrap();
let _ = pgsafe()
.arg("--fix")
.arg(rel)
.current_dir(dir2.path())
.assert();
let applied_via_fix = fs::read_to_string(dir2.path().join(rel)).unwrap();
assert_eq!(applied_via_diff, applied_via_fix);
}
#[test]
fn diff_cross_hunk_delta_applies_cleanly_with_git_apply() {
if !git_available() {
eprintln!("skipping: git not available");
return;
}
let dir = tempdir().unwrap();
assert!(StdCommand::new("git")
.arg("init")
.current_dir(dir.path())
.output()
.unwrap()
.status
.success());
let rel = "m.sql";
let original = "CREATE INDEX i ON a (x);\n\
SELECT 1;\nSELECT 2;\nSELECT 3;\nSELECT 4;\nSELECT 5;\nSELECT 6;\nSELECT 7;\n\
CREATE INDEX j ON b (y);\n";
fs::write(dir.path().join(rel), original).unwrap();
let diff = pgsafe()
.arg("--diff")
.arg(rel)
.current_dir(dir.path())
.assert();
let patch = String::from_utf8(diff.get_output().stdout.clone()).unwrap();
assert_eq!(
patch.lines().filter(|l| l.starts_with("@@ ")).count(),
2,
"expected two non-coalesced hunks:\n{patch}"
);
fs::write(dir.path().join("fix.patch"), &patch).unwrap();
let check = StdCommand::new("git")
.args(["apply", "--check", "fix.patch"])
.current_dir(dir.path())
.output()
.unwrap();
assert!(
check.status.success(),
"git apply --check failed:\n{}\n---patch---\n{patch}",
String::from_utf8_lossy(&check.stderr)
);
assert!(StdCommand::new("git")
.args(["apply", "fix.patch"])
.current_dir(dir.path())
.output()
.unwrap()
.status
.success());
let applied_via_diff = fs::read_to_string(dir.path().join(rel)).unwrap();
let dir2 = tempdir().unwrap();
fs::write(dir2.path().join(rel), original).unwrap();
let _ = pgsafe()
.arg("--fix")
.arg(rel)
.current_dir(dir2.path())
.assert();
let applied_via_fix = fs::read_to_string(dir2.path().join(rel)).unwrap();
assert_eq!(applied_via_diff, applied_via_fix);
}
#[test]
fn diff_reports_unfixable_in_mixed_case() {
pgsafe()
.arg("--diff")
.write_stdin("CREATE INDEX i ON t (x);\nDROP TABLE stale;\n")
.assert()
.failure()
.code(1)
.stdout(predicate::str::contains("+CREATE INDEX CONCURRENTLY"))
.stderr(predicate::str::contains("have no automatic fix"));
}
#[test]
fn diff_skipped_overlapping_not_silent() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join(".pgsafe.toml"),
"[forbidden-types]\ninteger = \"bigint\"\n",
)
.unwrap();
let f = dir.path().join("t.sql");
fs::write(&f, "CREATE TABLE t (id integer PRIMARY KEY);\n").unwrap();
pgsafe()
.arg("--diff")
.arg("t.sql")
.current_dir(dir.path())
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("skipped"))
.stderr(predicate::str::contains("overlapping"));
}
#[cfg(unix)]
#[test]
fn fix_preserves_file_mode() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let f = dir.path().join("m.sql");
fs::write(&f, "CREATE INDEX i ON t (x);\n").unwrap();
fs::set_permissions(&f, fs::Permissions::from_mode(0o640)).unwrap();
let _ = pgsafe().arg("--fix").arg(&f).assert();
assert!(fs::read_to_string(&f).unwrap().contains("CONCURRENTLY"));
let mode = fs::metadata(&f).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o640, "mode must be preserved, got {mode:o}");
}
#[cfg(unix)]
#[test]
fn fix_writes_through_symlink() {
use std::os::unix::fs::symlink;
let dir = tempdir().unwrap();
let real = dir.path().join("real.sql");
let link = dir.path().join("link.sql");
fs::write(&real, "CREATE INDEX i ON t (x);\n").unwrap();
symlink(&real, &link).unwrap();
let _ = pgsafe().arg("--fix").arg(&link).assert();
assert!(
fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink(),
"link must remain a symlink"
);
assert!(
fs::read_to_string(&real).unwrap().contains("CONCURRENTLY"),
"real file must be rewritten through the symlink"
);
let siblings: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
.collect();
assert!(siblings.is_empty(), "temp file left behind: {siblings:?}");
}
#[cfg(unix)]
#[test]
fn fix_temp_create_failure_exits_2() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let f = dir.path().join("m.sql");
fs::write(&f, "CREATE INDEX i ON t (x);\n").unwrap();
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o555)).unwrap();
let result = pgsafe().arg("--fix").arg(&f).assert();
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)).unwrap();
result.failure().code(2);
}
#[test]
fn diff_no_trailing_newline_applies_cleanly_with_git_apply() {
if !git_available() {
eprintln!("skipping: git not available");
return;
}
let dir = tempdir().unwrap();
assert!(StdCommand::new("git")
.arg("init")
.current_dir(dir.path())
.output()
.unwrap()
.status
.success());
let rel = "m.sql";
let original = "CREATE INDEX i ON t (x);"; fs::write(dir.path().join(rel), original).unwrap();
let diff = pgsafe()
.arg("--diff")
.arg(rel)
.current_dir(dir.path())
.assert();
let patch = String::from_utf8(diff.get_output().stdout.clone()).unwrap();
assert!(
patch.contains("\\ No newline at end of file"),
"patch must carry the no-newline marker:\n{patch}"
);
fs::write(dir.path().join("fix.patch"), &patch).unwrap();
let check = StdCommand::new("git")
.args(["apply", "--check", "fix.patch"])
.current_dir(dir.path())
.output()
.unwrap();
assert!(
check.status.success(),
"git apply --check failed:\n{}\n---patch---\n{patch}",
String::from_utf8_lossy(&check.stderr)
);
assert!(StdCommand::new("git")
.args(["apply", "fix.patch"])
.current_dir(dir.path())
.output()
.unwrap()
.status
.success());
let applied_via_diff = fs::read_to_string(dir.path().join(rel)).unwrap();
let dir2 = tempdir().unwrap();
fs::write(dir2.path().join(rel), original).unwrap();
let _ = pgsafe()
.arg("--fix")
.arg(rel)
.current_dir(dir2.path())
.assert();
let applied_via_fix = fs::read_to_string(dir2.path().join(rel)).unwrap();
assert_eq!(applied_via_diff, applied_via_fix);
}
#[test]
fn fix_write_is_atomic_no_temp_left_behind() {
let dir = tempdir().unwrap();
let f = dir.path().join("m.sql");
fs::write(&f, "CREATE INDEX i ON t (x);\n").unwrap();
let _ = pgsafe().arg("--fix").arg(&f).assert();
assert!(fs::read_to_string(&f).unwrap().contains("CONCURRENTLY"));
let siblings: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
.collect();
assert!(siblings.is_empty(), "temp file left behind: {siblings:?}");
}