pushkin 0.1.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! CLI integration conformance (Phase 1 test plan — read-only once committed).

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", "pydantic", "rust", "sql"]

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

[gates]
suppression_comments = "deny"
protected_paths = ["pushkin.toml", ".claude/settings.json", "schemas/**"]
"#;

const USER_SCHEMA: &str = r#"{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "name": { "type": "string", "minLength": 1, "maxLength": 200 },
    "email": { "type": "string", "format": "email" },
    "role": { "default": "member", "type": "string", "enum": ["member", "admin"] }
  },
  "required": ["name", "email"],
  "additionalProperties": false
}"#;

fn repo_with_manifest() -> std::io::Result<tempfile::TempDir> {
    let dir = tempfile::tempdir()?;
    fs::write(dir.path().join("pushkin.toml"), MANIFEST)?;
    fs::create_dir_all(dir.path().join("schemas"))?;
    fs::write(dir.path().join("schemas/user.schema.json"), USER_SCHEMA)?;
    Ok(dir)
}

fn pushkin() -> Result<Command, assert_cmd::cargo::CargoError> {
    Command::cargo_bin("pushkin")
}

#[test]
fn check_reads_stdin_and_exits_2_on_block() {
    let dir = repo_with_manifest().unwrap();
    let payload = r#"{"tool_name":"Write","tool_input":{"file_path":"app/api/users/route.ts","content":"export async function POST(req: Request) {\n  const body = await req.json();\n  return Response.json({ name: body.name });\n}\n"}}"#;

    let assert = pushkin()
        .unwrap()
        .current_dir(dir.path())
        .arg("check")
        .write_stdin(payload)
        .assert()
        .code(2);
    let stderr = String::from_utf8_lossy(&assert.get_output().stderr).to_string();
    assert!(stderr.contains("contract.boundary.unvalidated_input"));
    assert!(stderr.contains("fix"));
}

#[test]
fn check_exits_0_on_conforming_write() {
    let dir = repo_with_manifest().unwrap();
    let payload = r#"{"tool_name":"Write","tool_input":{"file_path":"app/api/users/route.ts","content":"import { UserCreateSchema } from \"../../../contracts/user.zod\";\n\nexport async function POST(req: Request) {\n  const body = UserCreateSchema.parse(await req.json());\n  return Response.json(body);\n}\n"}}"#;

    pushkin()
        .unwrap()
        .current_dir(dir.path())
        .arg("check")
        .write_stdin(payload)
        .assert()
        .code(0);
}

#[test]
fn check_fails_open_on_malformed_stdin_but_logs_event() {
    let dir = repo_with_manifest().unwrap();

    pushkin()
        .unwrap()
        .current_dir(dir.path())
        .arg("check")
        .write_stdin("not json {{{")
        .assert()
        .code(0)
        .stderr(predicates::str::contains("pushkin"));

    // Review directive: the fail-open must be visible in the event log.
    let db = dir.path().join(".pushkin/events.db");
    assert!(
        db.exists(),
        "fail-open must still open and write the event log"
    );
}

#[test]
fn malformed_payload_with_protected_path_target_fails_closed() {
    // Review directive: partial parse revealing a protected-path target
    // fails CLOSED even when the full payload does not validate.
    let dir = repo_with_manifest().unwrap();
    let payload =
        r#"{"tool_input":{"file_path":".claude/settings.json"},"unexpected":[1,2,{"deep":true}]}"#;

    pushkin()
        .unwrap()
        .current_dir(dir.path())
        .arg("check")
        .write_stdin(payload)
        .assert()
        .code(2)
        .stderr(predicates::str::contains("pushkin.protected_path"));
}

#[test]
fn compile_writes_all_four_targets() {
    let dir = repo_with_manifest().unwrap();

    pushkin()
        .unwrap()
        .current_dir(dir.path())
        .arg("compile")
        .assert()
        .code(0);

    for file in [
        "generated/user.zod.gen.ts",
        "generated/user_models.gen.py",
        "generated/user.gen.rs",
        "generated/user.gen.sql",
    ] {
        let path = dir.path().join(file);
        assert!(path.exists(), "missing {file}");
        let content = fs::read_to_string(&path).unwrap();
        assert!(
            content.contains("pushkin-epoch:"),
            "{file} missing epoch header"
        );
    }
}

#[test]
fn compile_twice_is_byte_identical() {
    let dir = repo_with_manifest().unwrap();
    pushkin()
        .unwrap()
        .current_dir(dir.path())
        .arg("compile")
        .assert()
        .code(0);
    let first = fs::read_to_string(dir.path().join("generated/user.gen.rs")).unwrap();
    pushkin()
        .unwrap()
        .current_dir(dir.path())
        .arg("compile")
        .assert()
        .code(0);
    let second = fs::read_to_string(dir.path().join("generated/user.gen.rs")).unwrap();
    assert_eq!(first, second);
}

#[test]
fn check_emits_events_to_sqlite() {
    let dir = repo_with_manifest().unwrap();
    let payload = r#"{"tool_name":"Write","tool_input":{"file_path":".claude/settings.json","content":"{}"}}"#;

    pushkin()
        .unwrap()
        .current_dir(dir.path())
        .arg("check")
        .write_stdin(payload)
        .assert()
        .code(2);

    let db = dir.path().join(".pushkin/events.db");
    assert!(
        db.exists(),
        "every gate decision is an appended event (charter N7)"
    );
}

#[test]
fn doctor_reports_missing_hook() {
    let dir = repo_with_manifest().unwrap();
    // No .claude/settings.json installed: doctor must exit non-zero and say so.
    let assert = pushkin()
        .unwrap()
        .current_dir(dir.path())
        .arg("doctor")
        .assert()
        .code(1);
    let stdout = String::from_utf8_lossy(&assert.get_output().stdout).to_string();
    assert!(
        stdout.contains("hook"),
        "doctor must name the missing hook: {stdout}"
    );
}

#[test]
fn init_is_idempotent_at_same_consent_version() {
    let dir = repo_with_manifest().unwrap();
    pushkin()
        .unwrap()
        .current_dir(dir.path())
        .arg("init")
        .assert()
        .code(0);
    let settings_first = fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();

    pushkin()
        .unwrap()
        .current_dir(dir.path())
        .arg("init")
        .assert()
        .code(0);
    let settings_second = fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
    assert_eq!(
        settings_first, settings_second,
        "second init must not duplicate hooks"
    );

    // After init, doctor is green.
    //
    // §1.4 per-edit override (adapter-hook-portability pass, A3(b)),
    // authorized as an extension of the conditional pre-authorization; the
    // ENTIRE content of this edit is explicit PATH injection. A3(b) makes an
    // installed pack with an unresolvable `pushkin` a finding, so this
    // assertion would otherwise depend on the runner's ambient PATH — green
    // on a developer box with `cargo install`ed pushkin, red on CI. The
    // binary's own directory is injected so resolvability is a declared
    // precondition, not an accident of the environment. No assertion is
    // weakened: doctor must still exit 0.
    let binary_dir = std::path::Path::new(env!("CARGO_BIN_EXE_pushkin"))
        .parent()
        .unwrap();
    pushkin()
        .unwrap()
        .current_dir(dir.path())
        .env("PATH", binary_dir)
        .arg("doctor")
        .assert()
        .code(0);
}