pushkin 0.1.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Phase 5 task 8 conformance (exit-demonstration finding): live Atlas
//! refuses a file:// schema side without a dev database for
//! normalization (`--dev-url cannot be empty`). The fake-tool suite
//! could not see this — the real binary did. `pushkin db drift` must
//! forward `PUSHKIN_ATLAS_DEV_URL` as `--dev-url` when set, and omit
//! the flag when unset (Atlas's own error then names the requirement).
//! 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"
"#;

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

fn fake_atlas(bin: &Path) -> Option<()> {
    let argv_log = bin.join("atlas.argv");
    let script = format!(
        "#!/bin/sh\necho \"$@\" > '{}'\necho 'Schemas are synced.'\nexit 0\n",
        argv_log.display()
    );
    let path = bin.join("atlas");
    fs::write(&path, script).ok()?;
    fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).ok()?;
    Some(())
}

fn run_drift(dir: &Path, bin: &Path, dev_url: Option<&str>) -> Option<String> {
    let mut cmd = Command::cargo_bin("pushkin").ok()?;
    cmd.args(["db", "drift"])
        .current_dir(dir)
        .env("PATH", format!("{}:/usr/bin:/bin", bin.display()))
        .env("DATABASE_URL", "postgres://localhost:5432/app");
    match dev_url {
        Some(url) => cmd.env("PUSHKIN_ATLAS_DEV_URL", url),
        None => cmd.env_remove("PUSHKIN_ATLAS_DEV_URL"),
    };
    cmd.output().ok()?;
    fs::read_to_string(bin.join("atlas.argv")).ok()
}

#[test]
fn dev_url_env_forwarded_to_atlas() {
    let dir = repo().unwrap();
    let bin = tempfile::tempdir().unwrap();
    fake_atlas(bin.path()).unwrap();
    let argv = run_drift(
        dir.path(),
        bin.path(),
        Some("postgres://localhost:5432/dev?sslmode=disable"),
    )
    .unwrap();
    assert!(
        argv.contains("--dev-url") && argv.contains("postgres://localhost:5432/dev"),
        "PUSHKIN_ATLAS_DEV_URL must be forwarded as --dev-url: {argv}"
    );
}

#[test]
fn no_dev_url_env_means_no_flag() {
    let dir = repo().unwrap();
    let bin = tempfile::tempdir().unwrap();
    fake_atlas(bin.path()).unwrap();
    let argv = run_drift(dir.path(), bin.path(), None).unwrap();
    assert!(
        !argv.contains("--dev-url"),
        "without the env var the flag must be absent (Atlas names the need): {argv}"
    );
}