pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! `pushkin affected [--base <ref>]` (spec §12, §15 Phase 5): git diff →
//! touched contracts via the manifest → blast radius (generated bindings +
//! mapped globs) → targeted checks on the touched mapped files only. Exit 0
//! when the touched files conform, 2 when any violates — the same semantics
//! as `check`, scoped to the change.

use anyhow::Result;
use pushkin_core::manifest::{ContractName, Manifest};
use pushkin_core::pipeline::{check_write, WriteRequest};
use std::collections::BTreeSet;

use super::{apply_waivers, load_manifest};

pub fn run(base: Option<&str>) -> Result<i32> {
    let manifest = load_manifest()?;
    let files = changed_files(base)?;

    let touched: Vec<&String> = files
        .iter()
        .filter(|file| manifest.mapping_for(file).is_some())
        .collect();
    if touched.is_empty() {
        println!("pushkin affected: no mapped files in the diff.");
        return Ok(0);
    }

    let contracts: BTreeSet<&str> = touched
        .iter()
        .filter_map(|file| manifest.mapping_for(file))
        .flat_map(|mapping| mapping.contracts.iter().map(ContractName::as_str))
        .collect();

    println!("pushkin affected: touched mapped files");
    for file in &touched {
        println!("  {file}");
    }
    for name in &contracts {
        println!("contract: {name}");
        blast_radius(&manifest, name);
    }

    let mut blocked = false;
    for file in &touched {
        // Unreadable (e.g. deleted in this diff) — nothing to check.
        let Ok(content) = std::fs::read_to_string(file) else {
            continue;
        };
        let result = apply_waivers(check_write(
            &manifest,
            &WriteRequest {
                file_path: (*file).clone(),
                content,
            },
        ));
        for violation in &result.violations {
            blocked = true;
            println!(
                "  violation {}:{} [{}] — {}",
                violation.file, violation.line, violation.rule, violation.fix_hint
            );
        }
    }
    Ok(if blocked { 2 } else { 0 })
}

/// Bindings this contract emits (same naming scheme as `compile`) plus the
/// manifest globs it gates — what a contract change fans out to.
fn blast_radius(manifest: &Manifest, contract_name: &str) {
    let Some(contract) = manifest
        .contracts
        .iter()
        .find(|contract| contract.name.as_str() == contract_name)
    else {
        return;
    };
    for emit in &contract.emit {
        let file_name = match emit.as_str() {
            "zod" => format!("{contract_name}.zod.gen.ts"),
            "pydantic" => format!("{contract_name}_models.gen.py"),
            "rust" => format!("{contract_name}.gen.rs"),
            "sql" => format!("{contract_name}.gen.sql"),
            other => other.to_owned(),
        };
        println!("  binding: generated/{file_name}");
    }
    for mapping in &manifest.mappings {
        if mapping
            .contracts
            .iter()
            .any(|name| name.as_str() == contract_name)
        {
            println!("  mapped: {}", mapping.glob);
        }
    }
}

/// Changed file paths: staged (`--cached`) by default, `<base>...HEAD`
/// when a base ref is given. Rename entries surface as their new path.
fn changed_files(base: Option<&str>) -> Result<Vec<String>> {
    match base {
        Some(reference) => super::git::changed_in_range(reference),
        None => super::git::staged_files(),
    }
}