pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Phase 3 task 2: instruction channel stack (spec §7.2 channels 1/3/4).
//! Committed first, read-only hereafter (charter §4.1). Pins the digest
//! channel: `pushkin instructions --digest` renders the canonical
//! behavioral contract from the manifest (single source of truth), a
//! repo-local override file tunes the prose without recompiling, an env
//! var wins over the file, and the digest never exceeds 2 KiB while the
//! manifest-derived facts always survive (addendum §6 cap; §7.4
//! truncation invariant).

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

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

fn digest(dir: &tempfile::TempDir, envs: &[(&str, &str)]) -> (i32, String) {
    let Ok(mut command) = Command::cargo_bin("pushkin") else {
        return (-1, "cargo_bin resolution failed".to_owned());
    };
    command
        .current_dir(dir.path())
        .args(["instructions", "--digest"]);
    for (key, value) in envs {
        command.env(key, value);
    }
    let Ok(output) = command.output() else {
        return (-1, "spawn failed".to_owned());
    };
    (
        output.status.code().unwrap_or(-1),
        String::from_utf8_lossy(&output.stdout).into_owned(),
    )
}

#[test]
fn instructions_render_from_manifest_single_source() {
    let dir = repo().unwrap();
    let (code, output) = digest(&dir, &[]);
    assert_eq!(code, 0);
    assert!(
        output.contains("app/api/**/*.ts"),
        "gated globs come from THIS manifest: {output}"
    );
    assert!(output.contains("user"), "contract name present: {output}");
    assert!(
        output.contains(".claude/settings.json"),
        "protected paths named: {output}"
    );
}

#[test]
fn override_file_changes_wording_without_recompile() {
    let dir = repo().unwrap();
    fs::create_dir_all(dir.path().join(".pushkin")).unwrap();
    fs::write(
        dir.path().join(".pushkin/instructions.md"),
        "TEAM-TUNED PREAMBLE: obey the gate.\n",
    )
    .unwrap();

    let (code, output) = digest(&dir, &[]);
    assert_eq!(code, 0);
    assert!(
        output.contains("TEAM-TUNED PREAMBLE"),
        "override prose replaces the canonical wording: {output}"
    );
    assert!(
        output.contains("app/api/**/*.ts"),
        "manifest facts still rendered — prose cannot drift from enforcement: {output}"
    );
}

#[test]
fn env_var_override_wins_over_file() {
    let dir = repo().unwrap();
    fs::create_dir_all(dir.path().join(".pushkin")).unwrap();
    fs::write(
        dir.path().join(".pushkin/instructions.md"),
        "FILE OVERRIDE\n",
    )
    .unwrap();
    fs::write(dir.path().join("env-override.md"), "ENV OVERRIDE WINS\n").unwrap();

    let env_path = dir.path().join("env-override.md");
    let (code, output) = digest(
        &dir,
        &[("PUSHKIN_INSTRUCTIONS", env_path.to_str().unwrap())],
    );
    assert_eq!(code, 0);
    assert!(
        output.contains("ENV OVERRIDE WINS"),
        "env-named override wins: {output}"
    );
    assert!(
        !output.contains("FILE OVERRIDE"),
        "file override is superseded, not concatenated: {output}"
    );
}

#[test]
fn digest_capped_at_2kb() {
    let dir = repo().unwrap();
    fs::create_dir_all(dir.path().join(".pushkin")).unwrap();
    let huge = "VERBOSE TEAM PROSE. ".repeat(500); // ~10 KiB of override
    fs::write(dir.path().join(".pushkin/instructions.md"), huge).unwrap();

    let (code, output) = digest(&dir, &[]);
    assert_eq!(code, 0);
    assert!(
        output.len() <= 2048,
        "digest capped at 2 KiB (addendum §6), got {} bytes",
        output.len()
    );
    assert!(
        output.contains("app/api/**/*.ts"),
        "manifest facts survive truncation (§7.4 invariant): {output}"
    );
}