pushkin 0.2.0

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Remediation pass 4, T1 (F15) conformance: `scripts/ci/db-drift-changed.sh`,
//! the db-drift workflow's in-job change detection. A required status check
//! must ALWAYS report, so the workflow drops its `paths:` trigger filter and
//! this script decides — after the job has started — between the explicit
//! no-op success and the full drift+RLS gates. The decision is the
//! `changed=<true|false>` output line (stdout, and `$GITHUB_OUTPUT` when
//! set), never the exit code: every decision path exits 0 so the check
//! reports. Committed together with its red-demonstrated implementation per
//! the standing remediation-pass protocol; read-only hereafter (charter N10).

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])
}

/// Git repo with a seed commit that already contains a `sandbox/db` tree —
/// the detection question is whether the RANGE touches it, not whether it
/// exists.
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)
}

/// Runs the script exactly as the workflow step does (direct invocation, so
/// a missing executable bit fails here too) and returns
/// (stdout, stderr, exit code).
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();
    // Feature branch: docs-only change.
    git(dir.path(), &["checkout", "-q", "-b", "feature"]).unwrap();
    commit_file(dir.path(), "README.md", "seed\nfeature docs\n", "docs only").unwrap();
    // Base branch moves on with a sandbox/db change AFTER the branch point:
    // it must not be attributed to the feature branch (merge-base diff, not
    // a plain two-dot diff against the base tip).
    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}"
    );
}