pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! `pushkin db drift` / `pushkin db rls` (spec §5.3, §10): the DB drift
//! gates. `drift` runs an Atlas declarative diff with `db.direction`
//! picking which side is desired state; `rls` runs the pgTAP suite via
//! `supabase test db`. Both are CI-shaped: exit 0 = clean, 2 = drift or
//! regression (blocks), 1 = the gate itself could not run. A missing tool
//! is a loud, named failure carrying the install command — never a silent
//! skip (§7 rider). Same exit contract as `check`.

use anyhow::{bail, Result};
use pushkin_core::manifest::{Db, DbDirection, Manifest};

use super::external;
use super::load_manifest;

pub fn run_drift() -> Result<i32> {
    let manifest = load_manifest()?;
    let db = require_db(&manifest)?;
    let database_url = std::env::var("DATABASE_URL")
        .map_err(|_| anyhow::anyhow!("DATABASE_URL must be set for `pushkin db drift`"))?;

    // direction picks the truth side of the diff (spec §10): with
    // "contract" the generated DDL is desired state and the live DB is
    // diffed against it; "database" inverts that.
    let ddl = generated_sql_url(&manifest)?;
    let (from, to) = match db.direction {
        DbDirection::Contract => (database_url.as_str(), ddl.as_str()),
        DbDirection::Database => (ddl.as_str(), database_url.as_str()),
    };
    let mut args = vec![
        "schema",
        "diff",
        "--from",
        from,
        "--to",
        to,
        "--format",
        "{{ sql . }}",
    ];
    // Atlas needs a dev database to normalize a file:// schema side;
    // forward it when the operator/CI provides one. When unset, Atlas's
    // own error names the requirement — no silent fallback.
    let dev_url = std::env::var("PUSHKIN_ATLAS_DEV_URL").ok();
    if let Some(url) = dev_url.as_deref() {
        args.extend_from_slice(&["--dev-url", url]);
    }
    let output = external::atlas(&args)?;
    let stdout = String::from_utf8_lossy(&output.stdout);
    if !output.status.success() {
        bail!(
            "atlas schema diff failed: {}",
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }
    if is_synced(&stdout) {
        println!("pushkin db drift: schemas are synced.");
        return Ok(0);
    }
    println!(
        "pushkin db drift: schema drift detected (direction: {:?} is truth).\n\
         The following changes would be required:\n{stdout}",
        db.direction
    );
    Ok(2)
}

pub fn run_rls() -> Result<i32> {
    let manifest = load_manifest()?;
    require_db(&manifest)?;
    let output = external::supabase(&["test", "db"])?;
    let stdout = String::from_utf8_lossy(&output.stdout);
    if output.status.success() {
        println!("pushkin db rls: pgTAP suite passed.\n{stdout}");
        return Ok(0);
    }
    println!("pushkin db rls: pgTAP suite FAILED.\n{stdout}");
    Ok(2)
}

fn require_db(manifest: &Manifest) -> Result<&Db> {
    manifest.db.as_ref().ok_or_else(|| {
        anyhow::anyhow!(
            "no [db] table in pushkin.toml — declare direction/provider \
             before running the drift gates (spec §5.3)"
        )
    })
}

/// The `file://` URL of the concatenable generated SQL for the diff.
fn generated_sql_url(manifest: &Manifest) -> Result<String> {
    let sql_contract = manifest
        .contracts
        .iter()
        .find(|contract| contract.emit.iter().any(|emit| emit == "sql"));
    let Some(contract) = sql_contract else {
        bail!("no contract emits sql — nothing to diff against (add `emit = [\"sql\"]`)");
    };
    let path = format!("generated/{}.gen.sql", contract.name.as_str());
    if !std::path::Path::new(&path).exists() {
        bail!("generated DDL {path} not found — run `pushkin compile` first");
    }
    Ok(format!("file://{path}"))
}

fn is_synced(stdout: &str) -> bool {
    let trimmed = stdout.trim();
    trimmed.is_empty() || trimmed.to_ascii_lowercase().contains("synced")
}