pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Phase 3 task 4b: session-lifecycle injection through the hook verb
//! (spec §7.2 channel 1 + §7.3; addendum §6 table, Claude row).
//! Committed first, read-only hereafter (charter §4.1). Pins: a
//! `SessionStart` payload emits the digest as `additionalContext` once
//! per epoch (repeats are silent — never stacked); a compaction-sourced
//! `SessionStart` clears the scope and re-emits in full; the claude
//! install pack wires a marker-tagged `SessionStart` entry.

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

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

fn session_start(session: &str, source: &str) -> String {
    serde_json::json!({
        "session_id": session,
        "hook_event_name": "SessionStart",
        "source": source,
    })
    .to_string()
}

fn run_hook(dir: &tempfile::TempDir, payload: &str) -> (i32, 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.to_owned())
        .output()
    else {
        return (-1, "spawn failed".to_owned());
    };
    (
        output.status.code().unwrap_or(-1),
        String::from_utf8_lossy(&output.stdout).into_owned(),
    )
}

#[test]
fn session_start_injects_digest_once_per_epoch() {
    let dir = repo().unwrap();

    let (code, stdout) = run_hook(&dir, &session_start("s1", "startup"));
    assert_eq!(code, 0);
    let json: serde_json::Value = serde_json::from_str(&stdout)
        .unwrap_or_else(|_| panic!("SessionStart emits JSON, got: {stdout}"));
    let context = json["hookSpecificOutput"]["additionalContext"]
        .as_str()
        .unwrap_or_default();
    assert!(
        context.contains("app/api/**/*.ts"),
        "digest carries manifest facts: {context}"
    );

    let (repeat_code, repeat_stdout) = run_hook(&dir, &session_start("s1", "resume"));
    assert_eq!(repeat_code, 0);
    assert_eq!(
        repeat_stdout.trim(),
        "",
        "digest already delivered this epoch — never stacked (addendum §6)"
    );
}

#[test]
fn compact_source_clears_and_reinjects() {
    let dir = repo().unwrap();

    run_hook(&dir, &session_start("s1", "startup"));
    let (_, deduped) = run_hook(&dir, &session_start("s1", "resume"));
    assert_eq!(deduped.trim(), "", "precondition: epoch already delivered");

    let (code, stdout) = run_hook(&dir, &session_start("s1", "compact"));
    assert_eq!(code, 0);
    assert!(
        stdout.contains("app/api/**/*.ts"),
        "compaction cleared the scope — digest re-emitted in full (§7.3): {stdout}"
    );
}

#[test]
fn init_claude_installs_session_start_entry() {
    let dir = repo().unwrap();
    let Ok(mut command) = Command::cargo_bin("pushkin") else {
        panic!("cargo_bin resolution failed");
    };
    let status = command
        .current_dir(dir.path())
        .args(["init", "--agent", "claude"])
        .output()
        .unwrap()
        .status;
    assert!(status.success());

    let settings = fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
    assert!(
        settings.contains("SessionStart"),
        "SessionStart entry installed for lifecycle injection: {settings}"
    );
}