use std::fs;
use std::path::{Path, PathBuf};
const ZERO_SHA: &str = "0000000000000000000000000000000000000000";
fn script_path() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../scripts/ci/db-drift-changed.sh")
}
fn git(dir: &Path, args: &[&str]) -> Option<()> {
let status = std::process::Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "Drift Detect Tester")
.env("GIT_AUTHOR_EMAIL", "drift-detect@example.com")
.env("GIT_COMMITTER_NAME", "Drift Detect Tester")
.env("GIT_COMMITTER_EMAIL", "drift-detect@example.com")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.ok()?;
assert!(status.success(), "git {args:?} must succeed");
Some(())
}
fn commit_file(dir: &Path, rel: &str, content: &str, message: &str) -> Option<()> {
let path = dir.join(rel);
fs::create_dir_all(path.parent()?).ok()?;
fs::write(&path, content).ok()?;
git(dir, &["add", rel])?;
git(dir, &["commit", "-q", "-m", message])
}
fn repo() -> Option<tempfile::TempDir> {
let dir = tempfile::tempdir().ok()?;
git(dir.path(), &["init", "-q", "-b", "main"])?;
fs::create_dir_all(dir.path().join("sandbox/db/migrations")).ok()?;
fs::write(
dir.path().join("sandbox/db/migrations/0001_users.sql"),
"CREATE TABLE users (id bigint PRIMARY KEY, name text NOT NULL);\n",
)
.ok()?;
fs::write(dir.path().join("README.md"), "seed\n").ok()?;
git(dir.path(), &["add", "."])?;
git(dir.path(), &["commit", "-q", "-m", "seed"])?;
Some(dir)
}
fn run_script(
dir: &Path,
base: &str,
github_output: Option<&Path>,
) -> Option<(String, String, i32)> {
let mut cmd = std::process::Command::new(script_path().canonicalize().ok()?);
cmd.arg(base).current_dir(dir).env_remove("GITHUB_OUTPUT");
if let Some(out) = github_output {
cmd.env("GITHUB_OUTPUT", out);
}
let output = cmd.output().ok()?;
Some((
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
output.status.code().unwrap_or(-1),
))
}
#[test]
fn untouched_sandbox_db_decides_noop_success() {
let dir = repo().unwrap();
commit_file(dir.path(), "README.md", "seed\nmore docs\n", "docs only").unwrap();
let (stdout, _, code) = run_script(dir.path(), "HEAD~1", None).unwrap();
assert_eq!(code, 0, "decision paths must exit 0 so the check reports");
assert!(
stdout.contains("changed=false"),
"a non-matching change must decide changed=false: {stdout}"
);
}
#[test]
fn sandbox_db_change_selects_the_gates() {
let dir = repo().unwrap();
commit_file(
dir.path(),
"sandbox/db/migrations/0002_email.sql",
"ALTER TABLE users ADD COLUMN email text;\n",
"add email column",
)
.unwrap();
let (stdout, stderr, code) = run_script(dir.path(), "HEAD~1", None).unwrap();
assert_eq!(code, 0, "decision paths must exit 0 so the check reports");
assert!(
stdout.contains("changed=true"),
"a sandbox/db change must decide changed=true: {stdout}"
);
assert!(
(stdout.clone() + &stderr).contains("sandbox/db/migrations/0002_email.sql"),
"the matching file must be named in the job log: {stdout}{stderr}"
);
}
#[test]
fn zero_sha_base_fails_toward_running_the_gates() {
let dir = repo().unwrap();
let (stdout, stderr, code) = run_script(dir.path(), ZERO_SHA, None).unwrap();
assert_eq!(code, 0, "decision paths must exit 0 so the check reports");
assert!(
stdout.contains("changed=true"),
"an unusable base must fail TOWARD running the gates: {stdout}"
);
assert!(
!stderr.is_empty(),
"the fallback must be loud, never silent (charter §4.4)"
);
}
#[test]
fn unresolvable_base_fails_toward_running_the_gates() {
let dir = repo().unwrap();
let (stdout, stderr, code) = run_script(dir.path(), "origin/no-such-ref", None).unwrap();
assert_eq!(code, 0, "decision paths must exit 0 so the check reports");
assert!(
stdout.contains("changed=true"),
"an unresolvable base must fail TOWARD running the gates: {stdout}"
);
assert!(
stderr.contains("origin/no-such-ref"),
"the loud fallback must name the base it could not resolve: {stderr}"
);
}
#[test]
fn decision_lands_in_github_output() {
let dir = repo().unwrap();
commit_file(dir.path(), "README.md", "seed\nmore docs\n", "docs only").unwrap();
let out_file = dir.path().join("github_output.txt");
fs::write(&out_file, "").unwrap();
let (_, _, code) = run_script(dir.path(), "HEAD~1", Some(&out_file)).unwrap();
assert_eq!(code, 0);
let recorded = fs::read_to_string(&out_file).unwrap();
assert!(
recorded.lines().any(|l| l == "changed=false"),
"the decision must land in $GITHUB_OUTPUT for step `if:` guards: {recorded}"
);
}
#[test]
fn diverged_base_changes_are_not_attributed() {
let dir = repo().unwrap();
git(dir.path(), &["checkout", "-q", "-b", "feature"]).unwrap();
commit_file(dir.path(), "README.md", "seed\nfeature docs\n", "docs only").unwrap();
git(dir.path(), &["checkout", "-q", "main"]).unwrap();
commit_file(
dir.path(),
"sandbox/db/migrations/0002_email.sql",
"ALTER TABLE users ADD COLUMN email text;\n",
"base moves on",
)
.unwrap();
git(dir.path(), &["checkout", "-q", "feature"]).unwrap();
let (stdout, _, code) = run_script(dir.path(), "main", None).unwrap();
assert_eq!(code, 0);
assert!(
stdout.contains("changed=false"),
"a sandbox/db change on the diverged base must not select the gates \
for a docs-only branch: {stdout}"
);
}