pushkin 0.1.0

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Phase 3 task 5: progressive response compression (spec §7.4).
//! Committed first, read-only hereafter (charter §4.1). Pins: the first
//! violation of a contract in a session carries the contract-slice
//! excerpt + fix hint; repeats collapse the excerpt to a one-line
//! pointer; the violated constraint (rule, file, fix) ALWAYS survives
//! the collapse; the savings are recorded as telemetry events.

use assert_cmd::Command;
use std::fs;

const MANIFEST: &str = r#"
version = 1
canonical = "json-schema-2020-12"
authoring = "zod"

[[contracts]]
name = "user"
source = "contracts/user.zod.ts"
emit = ["zod"]

[[mappings]]
glob = "app/api/**/*.ts"
contracts = ["user"]
require = "boundary-validation"

[gates]
protected_paths = ["pushkin.toml"]
"#;

const CONTRACT_SOURCE: &str = "import { z } from \"zod\";\n\n\
export const UserCreateSchema = z.object({ name: z.string(), email: z.string() }).strict();\n";

const NONCONFORMING: &str = "export async function POST(req: Request) {\n\
  const body = await req.json();\n\
  return Response.json({ name: body.name });\n\
}\n";

fn repo() -> std::io::Result<tempfile::TempDir> {
    let dir = tempfile::tempdir()?;
    fs::write(dir.path().join("pushkin.toml"), MANIFEST)?;
    fs::create_dir_all(dir.path().join("contracts"))?;
    fs::write(dir.path().join("contracts/user.zod.ts"), CONTRACT_SOURCE)?;
    Ok(dir)
}

fn deny(dir: &tempfile::TempDir, session: &str) -> (i32, String) {
    let payload = serde_json::json!({
        "session_id": session,
        "tool_name": "Write",
        "tool_input": { "file_path": "app/api/users/route.ts", "content": NONCONFORMING }
    })
    .to_string();
    let Ok(mut command) = Command::cargo_bin("pushkin") else {
        return (-1, "cargo_bin resolution failed".to_owned());
    };
    let Ok(output) = command
        .current_dir(dir.path())
        .args(["hook", "claude"])
        .write_stdin(payload)
        .output()
    else {
        return (-1, "spawn failed".to_owned());
    };
    (
        output.status.code().unwrap_or(-1),
        String::from_utf8_lossy(&output.stdout).into_owned(),
    )
}

#[test]
fn first_violation_carries_full_excerpt_and_fix_hint() {
    let dir = repo().unwrap();
    let (code, stdout) = deny(&dir, "c1");
    assert_eq!(code, 0);
    assert!(
        stdout.contains("UserCreateSchema = z.object"),
        "first denial carries the contract-slice excerpt from the authoring source: {stdout}"
    );
    assert!(stdout.contains("fix:"), "fix hint present: {stdout}");
}

#[test]
fn repeat_violation_is_one_line_pointer() {
    let dir = repo().unwrap();
    deny(&dir, "c1");
    let (code, stdout) = deny(&dir, "c1");
    assert_eq!(code, 0);
    assert!(
        stdout.contains("already shown — unchanged"),
        "repeat collapses the excerpt to a pointer line: {stdout}"
    );
    assert!(
        !stdout.contains("UserCreateSchema = z.object"),
        "the excerpt body is not re-sent on repeats: {stdout}"
    );
}

#[test]
fn violated_constraint_survives_truncation() {
    let dir = repo().unwrap();
    deny(&dir, "c1");
    let (_, stdout) = deny(&dir, "c1");
    assert!(
        stdout.contains("contract.boundary.unvalidated_input"),
        "rule id survives the collapse (§7.4 invariant): {stdout}"
    );
    assert!(
        stdout.contains("app/api/users/route.ts"),
        "target file survives: {stdout}"
    );
    assert!(stdout.contains("fix:"), "fix line survives: {stdout}");
}

#[test]
fn savings_recorded_as_events() {
    let dir = repo().unwrap();
    deny(&dir, "c1");
    deny(&dir, "c1");
    let conn = rusqlite::Connection::open(dir.path().join(".pushkin/events.db")).unwrap();
    let saved: u64 = conn
        .query_row(
            "SELECT COUNT(*) FROM events WHERE rule = 'pushkin.compression'",
            [],
            |row| row.get(0),
        )
        .unwrap();
    assert!(
        saved >= 1,
        "pointer emissions record their savings as telemetry events (spec §14)"
    );
}