pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Facade for non-git external tools (R1, charter
//! `docs/charters/2026-08-20-r1-facades.md` §3.2).
//!
//! One door per tool: named entry points (`atlas`, `supabase`, `bun_emit`) over
//! a single private `spawn` core. There is deliberately **no** `pub fn run(name,
//! args)` — a facade that takes raw argv is not a facade (same rule `git.rs`
//! holds). The shared part is only the spawn: the missing-tool probe rendered in
//! ONE place (a positively-probed absent binary is a loud, named failure with
//! its install command, never a silent skip — §7 rider). Interpreting a tool's
//! output stays with its caller; this module never accretes result parsing or
//! git (that is `git.rs`).

use anyhow::{bail, Result};
use std::process::Output;

/// Run a required external tool, capturing its output. A binary that is absent
/// from `PATH` becomes a loud, named error carrying the install command; any
/// other spawn failure is reported verbatim. The private core behind every
/// named entry point below.
fn spawn(name: &str, args: &[&str], install: &str) -> Result<Output> {
    match std::process::Command::new(name).args(args).output() {
        Ok(output) => Ok(output),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => bail!(
            "`{name}` is required for this gate but is not installed \
             (install it: `{install}`)"
        ),
        Err(error) => bail!("`{name}` could not be run: {error}"),
    }
}

const ATLAS_INSTALL: &str = "brew install ariga/tap/atlas";
const SUPABASE_INSTALL: &str = "brew install supabase/tap/supabase";
const BUN_INSTALL: &str = "curl -fsSL https://bun.sh/install | bash";

/// The Atlas schema-diff tool (`pushkin db drift`). Caller supplies the argv
/// after the binary name and interprets stdout.
///
/// # Errors
/// Atlas absent from `PATH`, or the process could not be spawned.
pub fn atlas(args: &[&str]) -> Result<Output> {
    spawn("atlas", args, ATLAS_INSTALL)
}

/// The Supabase CLI (`pushkin db rls`, `supabase test db`). Caller supplies the
/// argv and interprets stdout.
///
/// # Errors
/// Supabase absent from `PATH`, or the process could not be spawned.
pub fn supabase(args: &[&str]) -> Result<Output> {
    spawn("supabase", args, SUPABASE_INSTALL)
}

/// Evaluate a `bun -e <script>` emission with the given environment, for Zod
/// authoring (spec §4.2). Bun drops argv on `-e`, so inputs are passed as env
/// vars — the caller owns their names and the emitted-schema handling; this
/// entry owns only the spawn and the missing-runtime failure.
///
/// # Errors
/// Bun absent from `PATH`, or the process could not be spawned.
pub fn bun_emit(script: &str, envs: &[(&str, &str)]) -> Result<Output> {
    let mut command = std::process::Command::new("bun");
    command.arg("-e").arg(script);
    for (key, value) in envs {
        command.env(key, value);
    }
    match command.output() {
        Ok(output) => Ok(output),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => bail!(
            "`bun` is required for Zod authoring (spec §4.2) but is not installed \
             (install it: `{BUN_INSTALL}`)"
        ),
        Err(error) => bail!("`bun` could not be run: {error}"),
    }
}