pushkin 0.1.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Phase 5 task 3 conformance: peer board + claims + run isolation
//! (spec §9). Persisted `SQLite` board in `.pushkin/board.db`; every verb
//! scoped by a run ID; `claim`/`release` on files feed the pre-write gate:
//! an edit to a path another agent holds is deterministically denied.
//! Every MCP board tool has a CLI twin — these tests exercise the CLI
//! (`pushkin board <verb>`), which is the subagent path. Committed first,
//! read-only hereafter (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 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)
}

/// `pushkin board <args>` as agent `agent` in run `run`.
fn board(dir: &Path, run: &str, agent: &str, args: &[&str]) -> Option<std::process::Output> {
    let mut all = vec!["board"];
    all.extend_from_slice(args);
    all.extend_from_slice(&["--run", run, "--agent", agent]);
    Command::cargo_bin("pushkin")
        .ok()?
        .args(all)
        .current_dir(dir)
        .output()
        .ok()
}

fn stdout_of(output: &std::process::Output) -> String {
    String::from_utf8_lossy(&output.stdout).into_owned()
}

/// Claude-hook write attempt by `agent` in `run`; None = allowed
/// (silence), Some(reason) = denied. Test-fatal failures inside are fine —
/// an allowed write and a broken binary both surface as assertion context
/// at the call site, and every call site asserts the direction it expects.
fn hook_write(dir: &Path, run: &str, agent: &str, file_path: &str) -> Option<String> {
    let payload = serde_json::json!({
        "session_id": agent,
        "tool_name": "Write",
        "tool_input": { "file_path": file_path, "content": CONFORMING }
    })
    .to_string();
    let output = Command::cargo_bin("pushkin")
        .ok()?
        .args(["hook", "claude"])
        .env("PUSHKIN_RUN_ID", run)
        .env("PUSHKIN_AGENT_ID", agent)
        .current_dir(dir)
        .write_stdin(payload)
        .output()
        .ok()?;
    let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
    if stdout.trim().is_empty() {
        return None;
    }
    let json: serde_json::Value = serde_json::from_str(&stdout).ok()?;
    Some(
        json["hookSpecificOutput"]["permissionDecisionReason"]
            .as_str()?
            .to_owned(),
    )
}

#[test]
fn register_then_peers_lists_agent_scoped_by_run() {
    let dir = repo().unwrap();
    let out = board(dir.path(), "run-1", "agent-a", &["register"]).unwrap();
    assert!(out.status.success(), "register must succeed");
    board(dir.path(), "run-1", "agent-b", &["register"]).unwrap();
    board(dir.path(), "run-2", "agent-c", &["register"]).unwrap();

    let peers = stdout_of(&board(dir.path(), "run-1", "agent-a", &["peers"]).unwrap());
    assert!(peers.contains("agent-b"), "same-run peer listed: {peers}");
    assert!(
        !peers.contains("agent-c"),
        "other-run agent must not appear: {peers}"
    );
}

#[test]
fn broadcast_read_advances_cursor() {
    let dir = repo().unwrap();
    board(dir.path(), "run-1", "agent-a", &["register"]).unwrap();
    board(dir.path(), "run-1", "agent-b", &["register"]).unwrap();
    board(
        dir.path(),
        "run-1",
        "agent-a",
        &["broadcast", "--message", "contracts frozen for release"],
    )
    .unwrap();

    let first = stdout_of(&board(dir.path(), "run-1", "agent-b", &["read"]).unwrap());
    assert!(
        first.contains("contracts frozen for release"),
        "first read must deliver the broadcast: {first}"
    );
    let second = stdout_of(&board(dir.path(), "run-1", "agent-b", &["read"]).unwrap());
    assert!(
        !second.contains("contracts frozen for release"),
        "auto-cursor: second read must not redeliver: {second}"
    );
}

#[test]
fn send_is_directed_and_read_by_recipient_only() {
    let dir = repo().unwrap();
    for agent in ["agent-a", "agent-b", "agent-c"] {
        board(dir.path(), "run-1", agent, &["register"]).unwrap();
    }
    board(
        dir.path(),
        "run-1",
        "agent-a",
        &[
            "send",
            "--to",
            "agent-b",
            "--message",
            "take the users route",
        ],
    )
    .unwrap();
    let b_read = stdout_of(&board(dir.path(), "run-1", "agent-b", &["read"]).unwrap());
    assert!(
        b_read.contains("take the users route"),
        "recipient must receive the direct message: {b_read}"
    );
    let c_read = stdout_of(&board(dir.path(), "run-1", "agent-c", &["read"]).unwrap());
    assert!(
        !c_read.contains("take the users route"),
        "non-recipient must not see a directed message: {c_read}"
    );
}

#[test]
fn status_verb_records_and_peers_shows_it() {
    let dir = repo().unwrap();
    board(dir.path(), "run-1", "agent-a", &["register"]).unwrap();
    board(
        dir.path(),
        "run-1",
        "agent-a",
        &["status", "--message", "working app/api/users"],
    )
    .unwrap();
    board(dir.path(), "run-1", "agent-b", &["register"]).unwrap();
    let peers = stdout_of(&board(dir.path(), "run-1", "agent-b", &["peers"]).unwrap());
    assert!(
        peers.contains("working app/api/users"),
        "peers must surface the last status: {peers}"
    );
}

#[test]
fn claim_blocks_other_agents_write() {
    let dir = repo().unwrap();
    board(dir.path(), "run-1", "agent-a", &["register"]).unwrap();
    board(dir.path(), "run-1", "agent-b", &["register"]).unwrap();
    let out = board(
        dir.path(),
        "run-1",
        "agent-a",
        &["claim", "--path", "app/api/users/**"],
    )
    .unwrap();
    assert!(out.status.success(), "claim must succeed");

    // Holder writes fine.
    assert!(
        hook_write(dir.path(), "run-1", "agent-a", "app/api/users/route.ts").is_none(),
        "the claim holder's own write must pass"
    );
    // Another agent in the same run is denied, holder named.
    let reason = hook_write(dir.path(), "run-1", "agent-b", "app/api/users/route.ts")
        .expect("write to a claimed path by another agent must deny");
    assert!(
        reason.contains("agent-a"),
        "denial must name the claim holder: {reason}"
    );
}

#[test]
fn release_unblocks_claimed_path() {
    let dir = repo().unwrap();
    board(dir.path(), "run-1", "agent-a", &["register"]).unwrap();
    board(dir.path(), "run-1", "agent-b", &["register"]).unwrap();
    board(
        dir.path(),
        "run-1",
        "agent-a",
        &["claim", "--path", "app/api/users/**"],
    )
    .unwrap();
    let out = board(
        dir.path(),
        "run-1",
        "agent-a",
        &["release", "--path", "app/api/users/**"],
    )
    .unwrap();
    assert!(out.status.success(), "release must succeed");
    assert!(
        hook_write(dir.path(), "run-1", "agent-b", "app/api/users/route.ts").is_none(),
        "after release the other agent's write must pass"
    );
}

#[test]
fn claims_do_not_cross_run_ids() {
    let dir = repo().unwrap();
    board(dir.path(), "run-1", "agent-a", &["register"]).unwrap();
    board(
        dir.path(),
        "run-1",
        "agent-a",
        &["claim", "--path", "app/api/users/**"],
    )
    .unwrap();
    // Same path, DIFFERENT run: no intersection by design (spec §9).
    assert!(
        hook_write(dir.path(), "run-2", "agent-z", "app/api/users/route.ts").is_none(),
        "a claim in run-1 must not gate run-2"
    );
}

#[test]
fn board_survives_process_restart() {
    let dir = repo().unwrap();
    board(dir.path(), "run-1", "agent-a", &["register"]).unwrap();
    board(
        dir.path(),
        "run-1",
        "agent-a",
        &["claim", "--path", "app/api/users/**"],
    )
    .unwrap();
    // Every CLI call is its own process; persistence is already implicit.
    // Make it explicit: the claim is still enforced in a fresh process.
    let claims = stdout_of(&board(dir.path(), "run-1", "agent-a", &["claims"]).unwrap());
    assert!(
        claims.contains("app/api/users/**"),
        "claims must persist on disk across processes: {claims}"
    );
    assert!(
        Path::new(&dir.path().join(".pushkin/board.db")).exists(),
        "board lives in .pushkin/board.db (spec §10: embedded SQLite only)"
    );
}