pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! R3 command-normalization scope-guard — the verb-handler reshuffle changes
//! no observable behavior.
//!
//! Charter `docs/charters/2026-08-20-r3-command-normalization.md` §6/§7. R3
//! bundles the multi-bool verb handlers (`check`, `instructions`) into args
//! structs and moves `main.rs`'s `BoardCommand → BoardArgs` repack into a `From`
//! impl. Every one of those is an internal signature move behind an unchanged
//! clap surface, so this suite is a scope-guard (GREEN before and after, the R2
//! shape): it drives the three reshuffled arms through the compiled binary and
//! pins their observable output + exit, so a signature change that altered a
//! verdict, a rendered byte, or an exit code fails here.
//!
//! The existing `instructions_channels` and `peer_board` suites already pin the
//! full behavior of two of these verbs; this file is the focused trip-wire for
//! the R3 boundary specifically. A NEW file (N10).

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

type TestResult = Result<(), Box<dyn std::error::Error>>;

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"]
"#;

fn repo() -> Result<tempfile::TempDir, Box<dyn std::error::Error>> {
    let dir = tempfile::tempdir()?;
    fs::write(dir.path().join("pushkin.toml"), MANIFEST)?;
    Ok(dir)
}

struct Run {
    code: i32,
    stdout: String,
}

fn pushkin(
    dir: &Path,
    args: &[&str],
    stdin: Option<&str>,
) -> Result<Run, Box<dyn std::error::Error>> {
    let mut cmd = Command::cargo_bin("pushkin")?;
    cmd.current_dir(dir).env("PUSHKIN_DAEMON", "off").args(args);
    if let Some(payload) = stdin {
        cmd.write_stdin(payload.to_owned());
    }
    let out = cmd.output()?;
    Ok(Run {
        code: out.status.code().unwrap_or(-1),
        stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
    })
}

// ---------- check: the CheckArgs { staged, json } boundary ----------

const NONCONFORMING: &str = r#"{"session_id":"c","tool_name":"Write","tool_input":{"file_path":"app/api/users/route.ts","content":"export async function POST(req){ return Response.json(await req.json()); }"}}"#;

#[test]
fn check_json_blocks_a_nonconforming_write_with_the_envelope() -> TestResult {
    // The --json arm (json=true) still renders the §8.3 envelope and exits 2.
    let dir = repo()?;
    let run = pushkin(dir.path(), &["check", "--json"], Some(NONCONFORMING))?;
    assert_eq!(run.code, 2, "nonconforming write blocks: {}", run.stdout);
    assert!(
        run.stdout.contains("contract.boundary.unvalidated_input")
            && run.stdout.contains("\"block\""),
        "the --json envelope names the rule: {}",
        run.stdout
    );
    Ok(())
}

#[test]
fn check_allows_a_conforming_write() -> TestResult {
    // json=false (prose) arm, allow path — exit 0, no envelope.
    let dir = repo()?;
    let ok = r##"{"session_id":"c","tool_name":"Write","tool_input":{"file_path":"README.md","content":"# docs"}}"##;
    let run = pushkin(dir.path(), &["check"], Some(ok))?;
    assert_eq!(run.code, 0, "conforming write allows: {}", run.stdout);
    Ok(())
}

#[test]
fn check_staged_skips_when_nothing_is_staged() -> TestResult {
    // staged=true arm: with a git repo and an empty index, the staged sweep
    // finds nothing to gate and exits 0 — proving the flag still routes through
    // CheckArgs to the git-diff path.
    let dir = repo()?;
    std::process::Command::new("git")
        .args(["init", "-q"])
        .current_dir(dir.path())
        .output()?;
    let run = pushkin(dir.path(), &["check", "--staged"], None)?;
    assert_eq!(run.code, 0, "empty staged sweep is exit 0: {}", run.stdout);
    Ok(())
}

// ---------- instructions: the InstructionsArgs { for_subagent, digest } boundary ----------

#[test]
fn instructions_digest_is_capped_and_has_facts() -> TestResult {
    // digest=true arm.
    let dir = repo()?;
    let run = pushkin(dir.path(), &["instructions", "--digest"], None)?;
    assert_eq!(run.code, 0);
    assert!(
        run.stdout.len() <= 2048,
        "digest is ≤2 KiB: {} bytes",
        run.stdout.len()
    );
    assert!(
        run.stdout.contains("Gated paths:"),
        "digest carries the facts block: {}",
        run.stdout
    );
    Ok(())
}

#[test]
fn instructions_for_subagent_is_the_compact_variant() -> TestResult {
    // for_subagent=true arm.
    let dir = repo()?;
    let run = pushkin(dir.path(), &["instructions", "--for-subagent"], None)?;
    assert_eq!(run.code, 0);
    assert!(
        run.stdout.contains("This repository is gated by Pushkin"),
        "compact variant prose: {}",
        run.stdout
    );
    Ok(())
}

#[test]
fn instructions_full_is_the_default() -> TestResult {
    // both bools false — the full generated report.
    let dir = repo()?;
    let run = pushkin(dir.path(), &["instructions"], None)?;
    assert_eq!(run.code, 0);
    assert!(
        run.stdout
            .contains("gate instructions (generated from pushkin.toml)"),
        "full variant header: {}",
        run.stdout
    );
    Ok(())
}

// ---------- board: the BoardCommand → BoardArgs From-impl boundary ----------

#[test]
fn board_register_and_status_round_trip_through_the_repack() -> TestResult {
    // Exercises both a tuple-struct arm (Register) and a flattened-scope arm
    // (Status) so the From<BoardCommand> repack is proven on both shapes.
    let dir = repo()?;

    let reg = pushkin(
        dir.path(),
        &["board", "register", "--run", "r1", "--agent", "a1"],
        None,
    )?;
    assert_eq!(reg.code, 0, "register: {}", reg.stdout);
    assert!(
        reg.stdout.contains("a1 registered in r1"),
        "register prose: {}",
        reg.stdout
    );

    let status = pushkin(
        dir.path(),
        &[
            "board",
            "status",
            "--message",
            "working",
            "--run",
            "r1",
            "--agent",
            "a1",
        ],
        None,
    )?;
    assert_eq!(status.code, 0, "status: {}", status.stdout);
    assert!(
        status.stdout.contains("status recorded"),
        "status prose: {}",
        status.stdout
    );
    Ok(())
}