pushkin 0.2.0

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Phase 5 task 7 conformance: `pushkin report` + statusline segment
//! (integration doc §8; addendum §12; §7 task-split rider). Both are
//! read-only consumers of the event log. Report: denials by rule,
//! mean-time-to-compliance, active waivers. Statusline: compact segment,
//! escalation state wins, silent empty when no log exists (the one place
//! silence-on-failure is correct). Committed first, read-only (charter N10).

use assert_cmd::Command;
use std::fs;
use std::path::Path;

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]
suppression_comments = "deny"
protected_paths = ["pushkin.toml"]
"#;

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

const CONFORMING: &str = "import { UserSchema } from \"../../generated/user.zod.gen\";\n\
export async function POST(req: Request) {\n  \
const body = UserSchema.parse(await req.json());\n  \
return Response.json({ name: body.name });\n}\n";

fn repo() -> Option<tempfile::TempDir> {
    let dir = tempfile::tempdir().ok()?;
    fs::write(dir.path().join("pushkin.toml"), MANIFEST).ok()?;
    Some(dir)
}

/// One gated write through the Claude hook (same session for ladder/MTC).
fn hook_write(dir: &Path, session: &str, content: &str) -> Option<()> {
    let payload = serde_json::json!({
        "session_id": session,
        "tool_name": "Write",
        "tool_input": { "file_path": "app/api/users/route.ts", "content": content }
    })
    .to_string();
    Command::cargo_bin("pushkin")
        .ok()?
        .args(["hook", "claude"])
        .current_dir(dir)
        .write_stdin(payload)
        .output()
        .ok()?;
    Some(())
}

fn run_verb(dir: &Path, args: &[&str]) -> Option<(String, i32)> {
    let output = Command::cargo_bin("pushkin")
        .ok()?
        .args(args)
        .current_dir(dir)
        .output()
        .ok()?;
    Some((
        String::from_utf8_lossy(&output.stdout).into_owned(),
        output.status.code().unwrap_or(-1),
    ))
}

#[test]
fn report_counts_denials_by_rule_and_mtc() {
    let dir = repo().unwrap();
    // Session: deny, deny, then comply — MTC for that rule = 3 attempts.
    hook_write(dir.path(), "session-mtc", NONCONFORMING).unwrap();
    hook_write(dir.path(), "session-mtc", NONCONFORMING).unwrap();
    hook_write(dir.path(), "session-mtc", CONFORMING).unwrap();

    let (stdout, code) = run_verb(dir.path(), &["report"]).unwrap();
    assert_eq!(code, 0, "report must exit 0: {stdout}");
    assert!(
        stdout.contains("boundary-validation") && stdout.contains('2'),
        "denials by rule must show 2 boundary-validation denials: {stdout}"
    );
    assert!(
        stdout.to_lowercase().contains("mean-time-to-compliance")
            || stdout.to_lowercase().contains("attempts to fix"),
        "report must surface the MTC metric: {stdout}"
    );
}

#[test]
fn report_lists_active_waivers() {
    let dir = repo().unwrap();
    let out = Command::cargo_bin("pushkin")
        .unwrap()
        .args([
            "waive",
            "boundary-validation",
            "--path",
            "app/api/**",
            "--ttl",
            "2h",
            "--reason",
            "hotfix window",
        ])
        .env("GIT_AUTHOR_NAME", "Report Tester")
        .current_dir(dir.path())
        .output()
        .unwrap();
    assert!(out.status.success());
    let (stdout, _) = run_verb(dir.path(), &["report"]).unwrap();
    assert!(
        stdout.contains("hotfix window") && stdout.contains("app/api/**"),
        "active waivers must appear with scope and reason: {stdout}"
    );
}

#[test]
fn statusline_compact_segment_counts_checks_denials_waivers() {
    let dir = repo().unwrap();
    hook_write(dir.path(), "session-sl", NONCONFORMING).unwrap();
    hook_write(dir.path(), "session-sl", CONFORMING).unwrap();
    let (stdout, code) = run_verb(dir.path(), &["statusline"]).unwrap();
    assert_eq!(code, 0);
    let line = stdout.trim();
    assert!(
        line.starts_with("⛨ pushkin"),
        "segment must open with the pushkin mark: {line}"
    );
    assert!(
        line.contains("2 checks") && line.contains("1 denied"),
        "segment must carry check and denial counts: {line}"
    );
    assert!(
        !line.contains('\n'),
        "statusline must be a single line: {line}"
    );
}

#[test]
fn statusline_escalation_state_wins() {
    let dir = repo().unwrap();
    for _ in 0..3 {
        hook_write(dir.path(), "session-esc", NONCONFORMING).unwrap();
    }
    let (stdout, _) = run_verb(dir.path(), &["statusline"]).unwrap();
    assert!(
        stdout.contains("ESCALATED"),
        "three same-rule denials must flip the segment to ESCALATED: {stdout}"
    );
}

#[test]
fn statusline_degrades_to_empty_without_event_log() {
    let dir = repo().unwrap();
    let (stdout, code) = run_verb(dir.path(), &["statusline"]).unwrap();
    assert_eq!(code, 0, "no log must still exit 0");
    assert_eq!(
        stdout.trim(),
        "",
        "no event log = empty segment (the one place silence is correct)"
    );
}