pushkin 0.1.0

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! `pushkin compile`: authoring emission first (Zod source → canonical
//! JSON Schema, when the source exists), then strict bindings under
//! generated/, one file per emit target, epoch-stamped.

use anyhow::{bail, Context, Result};
use pushkin_compiler::{compile, CompileRequest, Target};
use pushkin_core::manifest::Manifest;

use super::authoring::{emit_schema_from_zod, export_name_for};
use super::load_manifest;

/// Regenerate ONE artifact under generated/ from its committed canonical
/// schema — the daemon's startup-queue worker (spec §5.2). The reverse
/// mapping (file name → contract + target) inverts `target_for`'s naming.
///
/// # Errors
/// A `String` reason (the queue's reporting type): unrecognized name,
/// unknown contract, missing schema, or a compile failure.
pub fn regenerate_one(manifest: &Manifest, file_name: &str) -> Result<(), String> {
    let (contract_name, target) = invert_target_for(file_name)
        .ok_or_else(|| format!("'{file_name}' does not match any generated-file pattern"))?;
    let contract = manifest
        .contracts
        .iter()
        .find(|c| c.name.as_str() == contract_name)
        .ok_or_else(|| format!("no contract '{contract_name}' in the manifest"))?;
    if !contract.emit.iter().any(|e| e == target_key(target)) {
        return Err(format!(
            "contract '{contract_name}' does not emit '{}'",
            target_key(target)
        ));
    }
    let schema_path = format!("schemas/{contract_name}.schema.json");
    let schema_json = std::fs::read_to_string(&schema_path)
        .map_err(|e| format!("cannot read {schema_path}: {e}"))?;
    let binding = compile(&CompileRequest {
        contract_name: contract_name.clone(),
        schema_json,
        target,
        // R9: the manifest owns the epoch; no binary constant remains.
        epoch: manifest.schema_epoch,
    })
    .map_err(|e| e.to_string())?;
    let out_path = format!("generated/{file_name}");
    std::fs::write(&out_path, binding.content).map_err(|e| format!("cannot write {out_path}: {e}"))
}

fn invert_target_for(file_name: &str) -> Option<(String, Target)> {
    let inversions: [(&str, Target); 4] = [
        (".zod.gen.ts", Target::Zod),
        ("_models.gen.py", Target::Pydantic),
        (".gen.rs", Target::Rust),
        (".gen.sql", Target::Sql),
    ];
    inversions.iter().find_map(|(suffix, target)| {
        file_name
            .strip_suffix(suffix)
            .map(|contract| (contract.to_owned(), *target))
    })
}

fn target_key(target: Target) -> &'static str {
    match target {
        Target::Zod => "zod",
        Target::Pydantic => "pydantic",
        Target::Rust => "rust",
        Target::Sql => "sql",
    }
}

pub fn run() -> Result<i32> {
    let manifest = load_manifest()?;
    std::fs::create_dir_all("generated").context("cannot create generated/")?;
    std::fs::create_dir_all("schemas").context("cannot create schemas/")?;

    for contract in &manifest.contracts {
        let schema_path = format!("schemas/{}.schema.json", contract.name.as_str());

        // Authoring stage (spec §4.2): a present Zod source is the truth and
        // refreshes the canonical schema. Absent source → the committed
        // canonical schema drives bindings (N1).
        if std::path::Path::new(&contract.source).exists() {
            let export = export_name_for(contract.name.as_str());
            let schema = emit_schema_from_zod(&contract.source, &export, manifest.schema_epoch)?;
            std::fs::write(&schema_path, schema)
                .with_context(|| format!("cannot write {schema_path}"))?;
            println!("wrote {schema_path}");
        }

        let schema_json = std::fs::read_to_string(&schema_path).with_context(|| {
            format!(
                "cannot read {schema_path} (no canonical schema and no authoring source \
                 '{}' to emit one from)",
                contract.source
            )
        })?;

        for emit in &contract.emit {
            let (target, file_name) = target_for(emit, contract.name.as_str())?;
            let binding = compile(&CompileRequest {
                contract_name: contract.name.as_str().to_owned(),
                schema_json: schema_json.clone(),
                target,
                epoch: manifest.schema_epoch,
            })?;
            let out_path = format!("generated/{file_name}");
            std::fs::write(&out_path, binding.content)
                .with_context(|| format!("cannot write {out_path}"))?;
            println!("wrote {out_path}");
        }
    }
    Ok(0)
}

fn target_for(emit: &str, contract: &str) -> Result<(Target, String)> {
    let pair = match emit {
        "zod" => (Target::Zod, format!("{contract}.zod.gen.ts")),
        "pydantic" => (Target::Pydantic, format!("{contract}_models.gen.py")),
        "rust" => (Target::Rust, format!("{contract}.gen.rs")),
        "sql" => (Target::Sql, format!("{contract}.gen.sql")),
        other => bail!("unknown emit target '{other}' (supported: zod, pydantic, rust, sql)"),
    };
    Ok(pair)
}