use std::path::Path;
use std::process::Command;
fn bless() -> bool {
["BLESS", "TYPEDPG_BLESS"]
.iter()
.any(|v| std::env::var_os(v).is_some_and(|s| !s.is_empty() && s != "0"))
}
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(dir)
.status()
.unwrap_or_else(|error| panic!("git {}: {error}", args.join(" ")));
assert!(status.success(), "git {} failed", args.join(" "));
}
fn copy_dir(from: &Path, to: &Path) {
std::fs::create_dir_all(to).unwrap();
for entry in std::fs::read_dir(from).unwrap() {
let entry = entry.unwrap();
let target = to.join(entry.file_name());
if entry.path().is_dir() {
copy_dir(&entry.path(), &target);
} else {
std::fs::copy(entry.path(), &target).unwrap();
}
}
}
#[test]
fn lint_ignores_migrations_unchanged_from_a_ref() {
let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let fixture = crate_dir.join("tests/lint_fixture");
let dir = tempfile::tempdir().unwrap();
copy_dir(&fixture, dir.path());
git(dir.path(), &["init", "-q"]);
git(
dir.path(),
&[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"add",
".",
],
);
git(
dir.path(),
&[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-q",
"-m",
"fixture",
],
);
let output = Command::new(env!("CARGO_BIN_EXE_cargo-typedpg"))
.args(["typedpg", "lint", "--manifest-path"])
.arg(dir.path().join("Cargo.toml"))
.args(["--ignore-migrations-from", "HEAD"])
.output()
.expect("run `cargo-typedpg`");
let stdout = String::from_utf8(output.stdout).expect("stdout is UTF-8");
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{stderr}");
assert!(!stdout.contains("create_index_blocking"), "{stdout}");
assert!(!stdout.contains("constraint_validating"), "{stdout}");
assert!(stdout.contains("fk_without_index"), "{stdout}");
assert!(
stdout.contains(
"CREATE INDEX CONCURRENTLY ON public.posts (author_id), in a new migration whose \
first line is -- no-transaction"
),
"{stdout}"
);
assert!(stdout.contains("unbounded_write"), "{stdout}");
assert!(stdout.contains("tests/ignored.rs:9:24"), "{stdout}");
assert!(
stderr.contains(
"checked 10 queries and 4 migrations (4 unchanged from HEAD: statement rules skipped)"
),
"{stderr}"
);
}
#[test]
fn lint_ignore_keeps_a_migrations_schema_findings() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::create_dir_all(root.join("migrations")).unwrap();
std::fs::write(
root.join("Cargo.toml"),
r#"[package]
name = "lint_ignore_fixture"
version = "0.0.0"
edition = "2024"
publish = false
[workspace]
[package.metadata.typedpg.database]
migrations = "./migrations"
[package.metadata.typedpg.lint]
ignore = ["migrations/0002_hazards.sql"]
"#,
)
.unwrap();
std::fs::write(root.join("src/lib.rs"), "").unwrap();
std::fs::write(
root.join("migrations/0001_init.sql"),
"CREATE TABLE users (id integer PRIMARY KEY);\n\
CREATE TABLE posts (id integer PRIMARY KEY, author_id integer);\n",
)
.unwrap();
std::fs::write(
root.join("migrations/0002_hazards.sql"),
"CREATE INDEX users_id_idx ON users (id);\n\
ALTER TABLE posts ADD CONSTRAINT posts_author_fk\n\
FOREIGN KEY (author_id) REFERENCES users (id);\n",
)
.unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_cargo-typedpg"))
.args(["typedpg", "lint", "--manifest-path"])
.arg(root.join("Cargo.toml"))
.output()
.expect("run `cargo-typedpg`");
let stdout = String::from_utf8(output.stdout).expect("stdout is UTF-8");
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(0), "{stderr}\n{stdout}");
assert!(!stdout.contains("create_index_blocking"), "{stdout}");
assert!(stdout.contains("warning[fk_without_index]"), "{stdout}");
assert!(
stdout.contains(
"CREATE INDEX CONCURRENTLY ON public.posts (author_id), in a new migration whose \
first line is -- no-transaction"
),
"{stdout}"
);
assert!(stdout.contains("info[redundant_index]"), "{stdout}");
assert!(
stdout.contains(
"DROP INDEX CONCURRENTLY users_id_idx, in a new migration whose first line is \
-- no-transaction"
),
"{stdout}"
);
}
#[test]
fn lint_fixture_reports_the_expected_documents() {
let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
let fixture = crate_dir.join("tests/lint_fixture");
for (format, expected) in [
("human", "expected.txt"),
("json", "expected.json"),
("sarif", "expected.sarif"),
] {
let output = Command::new(env!("CARGO_BIN_EXE_cargo-typedpg"))
.args(["typedpg", "lint", "--manifest-path"])
.arg(fixture.join("Cargo.toml"))
.args(["--format", format])
.output()
.unwrap_or_else(|error| panic!("run `cargo-typedpg` for {format}: {error}"));
let stdout = String::from_utf8(output.stdout).expect("stdout is UTF-8");
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(
output.status.code(),
Some(1),
"{format}: expected exit status 1, got {:?}\nstderr:\n{stderr}",
output.status
);
assert!(
!stderr.contains(": error:"),
"{format}: unexpected failure on stderr:\n{stderr}"
);
let lines: Vec<&str> = stderr.lines().collect();
assert!(lines[0].starts_with("typedpg lint: "), "{format}: {stderr}");
let checked = lines
.iter()
.find(|line| line.starts_with(" checked "))
.unwrap_or_else(|| panic!("{format}: no checked line in:\n{stderr}"));
assert!(checked.ends_with('s'), "{format}: {checked}");
assert!(
lines
.iter()
.any(|line| line.starts_with(" schema (default): ")),
"{format}: {stderr}"
);
assert!(
lines
.last()
.is_some_and(|line| line.starts_with(" ✖ failed: ")),
"{format}: {stderr}"
);
let path = fixture.join(expected);
if bless() {
std::fs::write(&path, &stdout).expect("write the expected document");
} else {
let expected = std::fs::read_to_string(&path).unwrap_or_else(|error| {
panic!(
"read {}: {error} — run with BLESS=1 to create it",
path.display()
)
});
assert_eq!(
stdout,
expected,
"{format}: stdout differs from {} — run with BLESS=1 to accept it",
path.display()
);
}
}
}