pushkin 0.1.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Phase 5 task 5 conformance: DB drift gates (spec §5.3, §10, §11).
//! `[db]` manifest table (`direction`/`provider`/`rls_tests`); `pushkin db
//! drift` = Atlas declarative diff with `db.direction` picking the truth
//! side; `pushkin db rls` = pgTAP via `supabase test db`. Unit tests run
//! against fake tool binaries emitting canned transcripts (§7 rider:
//! suite must run without the real tools; live atlas belongs to the exit
//! demonstration). Missing tool = loud, named failure with the install
//! command — never a silent skip. Committed first, read-only (charter N10).

use assert_cmd::Command;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;

const MANIFEST: &str = r#"
version = 1
canonical = "json-schema-2020-12"
authoring = "zod"

[[contracts]]
name = "user"
source = "contracts/user.zod.ts"
emit = ["zod", "sql"]

[[mappings]]
glob = "app/api/**/*.ts"
contracts = ["user"]
require = "boundary-validation"

[gates]
suppression_comments = "deny"
protected_paths = ["pushkin.toml"]

[db]
direction = "contract"
provider = "supabase"
rls_tests = "required"
"#;

/// Repo with a [db] table and one generated SQL artifact for the diff.
fn repo() -> Option<tempfile::TempDir> {
    let dir = tempfile::tempdir().ok()?;
    fs::write(dir.path().join("pushkin.toml"), MANIFEST).ok()?;
    fs::create_dir_all(dir.path().join("generated")).ok()?;
    fs::write(
        dir.path().join("generated/user.gen.sql"),
        "CREATE TABLE users (id uuid PRIMARY KEY, name text NOT NULL);\n",
    )
    .ok()?;
    Some(dir)
}

/// Drops a fake tool binary into `bin` that prints `stdout`, echoes its
/// argv to `<bin>/<name>.argv`, and exits with `code`.
fn fake_tool(bin: &Path, name: &str, stdout: &str, code: i32) -> Option<()> {
    let argv_log = bin.join(format!("{name}.argv"));
    let script = format!(
        "#!/bin/sh\necho \"$@\" > '{}'\ncat <<'PUSHKIN_EOF'\n{stdout}\nPUSHKIN_EOF\nexit {code}\n",
        argv_log.display()
    );
    let path = bin.join(name);
    fs::write(&path, script).ok()?;
    fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).ok()?;
    Some(())
}

/// Runs `pushkin db <args>` with PATH = `bin` only (plus /usr/bin:/bin
/// for sh) so exactly the fake tools are visible.
fn run_db(dir: &Path, bin: &Path, args: &[&str]) -> Option<(String, String, i32)> {
    let mut all = vec!["db"];
    all.extend_from_slice(args);
    let output = Command::cargo_bin("pushkin")
        .ok()?
        .args(all)
        .current_dir(dir)
        .env("PATH", format!("{}:/usr/bin:/bin", bin.display()))
        .env("DATABASE_URL", "postgres://localhost:5432/app")
        .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 db_section_parses_and_check_still_works() {
    let dir = repo().unwrap();
    let output = Command::cargo_bin("pushkin")
        .unwrap()
        .arg("check")
        .current_dir(dir.path())
        .output()
        .unwrap();
    assert!(
        output.status.success(),
        "a manifest with [db] must parse and check cleanly: {}",
        String::from_utf8_lossy(&output.stderr)
    );
}

#[test]
fn drift_without_atlas_fails_loud_with_install_hint() {
    let dir = repo().unwrap();
    let empty_bin = tempfile::tempdir().unwrap();
    let (_, stderr, code) = run_db(dir.path(), empty_bin.path(), &["drift"]).unwrap();
    assert_ne!(code, 0, "missing atlas must fail, never silently skip");
    assert!(
        stderr.contains("atlas") && stderr.contains("brew install ariga/tap/atlas"),
        "failure must name the tool and the install command: {stderr}"
    );
}

#[test]
fn contract_breaking_migration_fails_atlas_diff() {
    let dir = repo().unwrap();
    let bin = tempfile::tempdir().unwrap();
    fake_tool(
        bin.path(),
        "atlas",
        "-- Planned Changes:\nALTER TABLE users DROP COLUMN name;",
        0,
    )
    .unwrap();
    let (stdout, _, code) = run_db(dir.path(), bin.path(), &["drift"]).unwrap();
    assert_eq!(code, 2, "schema drift must block (exit 2): {stdout}");
    assert!(
        stdout.contains("DROP COLUMN"),
        "the drift verdict must carry the planned DDL: {stdout}"
    );
}

#[test]
fn synced_schema_passes_atlas_diff() {
    let dir = repo().unwrap();
    let bin = tempfile::tempdir().unwrap();
    fake_tool(
        bin.path(),
        "atlas",
        "Schemas are synced, no changes to be made.",
        0,
    )
    .unwrap();
    let (stdout, _, code) = run_db(dir.path(), bin.path(), &["drift"]).unwrap();
    assert_eq!(code, 0, "synced schemas must pass: {stdout}");
}

#[test]
fn drift_direction_contract_diffs_from_generated_sql() {
    let dir = repo().unwrap();
    let bin = tempfile::tempdir().unwrap();
    fake_tool(bin.path(), "atlas", "Schemas are synced.", 0).unwrap();
    run_db(dir.path(), bin.path(), &["drift"]).unwrap();
    let argv = fs::read_to_string(bin.path().join("atlas.argv")).unwrap();
    assert!(
        argv.contains("schema diff"),
        "drift must call atlas schema diff: {argv}"
    );
    // direction = "contract": generated DDL is desired state (--to), the
    // live database is current state (--from).
    assert!(
        argv.contains("--to") && argv.contains("generated/user.gen.sql"),
        "contract direction: generated SQL must be the desired (--to) side: {argv}"
    );
    assert!(
        argv.contains("--from") && argv.contains("postgres://"),
        "contract direction: the live DB must be the current (--from) side: {argv}"
    );
}

#[test]
fn rls_regression_fails_pgtap_check() {
    let dir = repo().unwrap();
    let bin = tempfile::tempdir().unwrap();
    fake_tool(
        bin.path(),
        "supabase",
        "failed tests: policy_users_select_own\nFAILED (failures=1)",
        1,
    )
    .unwrap();
    let (stdout, _, code) = run_db(dir.path(), bin.path(), &["rls"]).unwrap();
    assert_eq!(code, 2, "an RLS regression must block (exit 2): {stdout}");
    assert!(
        stdout.contains("policy_users_select_own"),
        "the verdict must carry the failing pgTAP output: {stdout}"
    );
}

#[test]
fn passing_pgtap_suite_exits_zero() {
    let dir = repo().unwrap();
    let bin = tempfile::tempdir().unwrap();
    fake_tool(bin.path(), "supabase", "All tests passed", 0).unwrap();
    let (_, _, code) = run_db(dir.path(), bin.path(), &["rls"]).unwrap();
    assert_eq!(code, 0, "a green pgTAP suite must pass");
}

#[test]
fn rls_without_supabase_fails_loud_with_install_hint() {
    let dir = repo().unwrap();
    let empty_bin = tempfile::tempdir().unwrap();
    let (_, stderr, code) = run_db(dir.path(), empty_bin.path(), &["rls"]).unwrap();
    assert_ne!(code, 0, "missing supabase CLI must fail, never skip");
    assert!(
        stderr.contains("supabase") && stderr.contains("brew install supabase/tap/supabase"),
        "failure must name the tool and the install command: {stderr}"
    );
}

#[test]
fn unknown_db_key_rejected_with_candidates() {
    let dir = tempfile::tempdir().unwrap();
    let bad = MANIFEST.replace("direction = \"contract\"", "dierction = \"contract\"");
    fs::write(dir.path().join("pushkin.toml"), bad).unwrap();
    let output = Command::cargo_bin("pushkin")
        .unwrap()
        .arg("check")
        .current_dir(dir.path())
        .output()
        .unwrap();
    assert!(!output.status.success(), "typo'd [db] key must be fatal");
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("direction"),
        "the error must suggest the intended key: {stderr}"
    );
}