pushkin 0.2.0

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Remediation (adapter hook portability) A1/A2: every adapter install
//! emits a PORTABLE hook command, and merges into user-owned config
//! instead of clobbering it.
//!
//! Field evidence: `hook_command()` embedded `std::env::current_exe()` into
//! every adapter's emitted command. That absolute path is machine- and
//! build-specific โ€” the root cause of the `.claude/settings.json` staleness
//! fixed by hand in d75765f, recurring whenever init re-runs or the binary
//! moves. The lefthook floor (L3(a)) retired the same construct for its own
//! surface; this generalizes the decision to the five adapter packs.
//!
//! A0 scheme: the bare PATH-resolved `pushkin hook <agent>`.
//!
//! Scoped exception, asserted here so it cannot silently regress: auggie's
//! settings entry keeps an ABSOLUTE path, because auggie only executes hook
//! commands that are script paths with a supported extension. The
//! portability fix there lands inside the generated script body.
//!
//! Committed first, read-only hereafter (charter ยง4.1, 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)
}

/// Runs pushkin with `HERMES_HOME` pinned inside the temp repo so the
/// hermes pack never writes into the developer's real ~/.hermes.
fn run(dir: &Path, args: &[&str]) -> Result<(Option<i32>, String), Box<dyn std::error::Error>> {
    let output = Command::cargo_bin("pushkin")?
        .current_dir(dir)
        .env("HERMES_HOME", dir.join(".hermes-home"))
        .args(args)
        .output()?;
    Ok((
        output.status.code(),
        format!(
            "{}{}",
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        ),
    ))
}

fn install(dir: &Path, agent: &str) -> Result<String, Box<dyn std::error::Error>> {
    let (code, output) = run(dir, &["init", "--agent", agent])?;
    if code != Some(0) {
        return Err(format!("init --agent {agent} failed: {output}").into());
    }
    Ok(output)
}

fn read(dir: &Path, relative: &str) -> Result<String, Box<dyn std::error::Error>> {
    Ok(fs::read_to_string(dir.join(relative))?)
}

/// The absolute path of the test binary that performs the install โ€” the
/// exact string `current_exe()` would embed.
fn installing_binary() -> &'static str {
    env!("CARGO_BIN_EXE_pushkin")
}

/// Every generated artifact that must carry no absolute binary path, keyed
/// by the agent that produces it.
fn portable_artifacts(agent: &str) -> &'static [&'static str] {
    match agent {
        "claude" => &[".claude/settings.json"],
        "codex" => &[".codex/hooks.json"],
        "auggie" => &[".augment/hooks/pushkin.sh"],
        "opencode" => &[".opencode/plugin/pushkin.ts"],
        _ => &[],
    }
}

// ---------- A1: no absolute path in emitted content ----------

#[test]
fn no_adapter_bakes_the_installing_binary_path_into_its_hook() -> TestResult {
    let binary = installing_binary();
    for agent in ["claude", "codex", "auggie", "opencode"] {
        let dir = repo()?;
        install(dir.path(), agent)?;
        for artifact in portable_artifacts(agent) {
            let content = read(dir.path(), artifact)?;
            assert!(
                !content.contains(binary),
                "{agent}: {artifact} embeds the installing binary's absolute \
                 path, so it goes stale when the binary moves: {content}"
            );
        }
    }
    Ok(())
}

#[test]
fn every_adapter_emits_the_portable_hook_command() -> TestResult {
    for agent in ["claude", "codex", "auggie", "opencode"] {
        let dir = repo()?;
        install(dir.path(), agent)?;
        let expected = format!("pushkin hook {agent}");
        let found = portable_artifacts(agent).iter().try_fold(
            false,
            |seen, artifact| -> Result<bool, Box<dyn std::error::Error>> {
                Ok(seen || read(dir.path(), artifact)?.contains(&expected))
            },
        )?;
        assert!(
            found,
            "{agent}: no generated artifact carries the A0-scheme command `{expected}`"
        );
    }
    Ok(())
}

#[test]
fn hermes_plugin_invokes_the_portable_command() -> TestResult {
    let dir = repo()?;
    install(dir.path(), "hermes")?;
    let plugin = read(dir.path(), ".hermes-home/plugins/pushkin-gate/__init__.py")?;
    assert!(
        !plugin.contains(installing_binary()),
        "hermes plugin embeds the installing binary's absolute path: {plugin}"
    );
    assert!(
        plugin.contains("pushkin"),
        "hermes plugin must still invoke pushkin: {plugin}"
    );
    Ok(())
}

/// The scoped exception: auggie's settings entry MUST stay an absolute
/// script path (auggie ignores bare commands), while the script it points
/// at carries the portable command.
#[test]
fn auggie_settings_keeps_an_absolute_script_path_but_the_script_is_portable() -> TestResult {
    let dir = repo()?;
    install(dir.path(), "auggie")?;

    let settings = read(dir.path(), ".augment/settings.json")?;
    assert!(
        settings.contains("pushkin.sh"),
        "auggie settings must point at the wrapper script: {settings}"
    );
    assert!(
        !settings.contains(installing_binary()),
        "the script path is auggie's requirement; the BINARY path is not: {settings}"
    );

    let script = read(dir.path(), ".augment/hooks/pushkin.sh")?;
    assert!(
        script.contains("pushkin hook auggie"),
        "the wrapper script body must use the portable command: {script}"
    );
    assert!(
        !script.contains(installing_binary()),
        "the wrapper script must not embed the installing binary path: {script}"
    );
    Ok(())
}

/// Mode/payload semantics are NOT this pass's surface: claude keeps all
/// three lifecycle events and its write matcher.
#[test]
fn claude_keeps_its_event_and_matcher_semantics() -> TestResult {
    let dir = repo()?;
    install(dir.path(), "claude")?;
    let settings = read(dir.path(), ".claude/settings.json")?;
    for needle in ["PreToolUse", "Stop", "SessionStart", "Write|Edit|MultiEdit"] {
        assert!(
            settings.contains(needle),
            "claude semantics changed โ€” {needle} missing: {settings}"
        );
    }
    Ok(())
}

/// The shell-quoting class L3(a) retired must not appear in any generated
/// adapter artifact either.
#[test]
fn no_adapter_emits_an_echod_stdin_payload() -> TestResult {
    for agent in ["claude", "codex", "auggie", "opencode"] {
        let dir = repo()?;
        install(dir.path(), agent)?;
        for artifact in portable_artifacts(agent) {
            let content = read(dir.path(), artifact)?;
            assert!(
                !content.contains("echo '{"),
                "{agent}: {artifact} carries an echo'd payload, the quoting \
                 class L3(a) retired: {content}"
            );
        }
    }
    Ok(())
}

// ---------- A2: merge, never clobber, per user-owned format ----------

#[test]
fn claude_install_preserves_foreign_keys() -> TestResult {
    let dir = repo()?;
    fs::create_dir_all(dir.path().join(".claude"))?;
    fs::write(
        dir.path().join(".claude/settings.json"),
        r#"{"model":"opus","hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"my-own-hook"}]}]}}"#,
    )?;

    install(dir.path(), "claude")?;
    let settings = read(dir.path(), ".claude/settings.json")?;
    assert!(
        settings.contains("\"model\""),
        "foreign top-level key dropped: {settings}"
    );
    assert!(
        settings.contains("my-own-hook"),
        "foreign hook entry dropped: {settings}"
    );
    assert!(
        settings.contains("pushkin hook claude"),
        "our own entry missing: {settings}"
    );
    Ok(())
}

#[test]
fn codex_install_preserves_foreign_keys() -> TestResult {
    let dir = repo()?;
    fs::create_dir_all(dir.path().join(".codex"))?;
    fs::write(
        dir.path().join(".codex/hooks.json"),
        r#"{"telemetry":false,"hooks":{"PreToolUse":[{"matcher":"shell","hooks":[{"type":"command","command":"team-hook"}]}]}}"#,
    )?;

    install(dir.path(), "codex")?;
    let hooks = read(dir.path(), ".codex/hooks.json")?;
    assert!(
        hooks.contains("\"telemetry\""),
        "foreign top-level key dropped: {hooks}"
    );
    assert!(
        hooks.contains("team-hook"),
        "foreign hook entry dropped: {hooks}"
    );
    Ok(())
}

#[test]
fn opencode_install_preserves_foreign_config_keys() -> TestResult {
    let dir = repo()?;
    fs::write(
        dir.path().join("opencode.json"),
        r#"{"theme":"dark","permission":{"edit":"allow"}}"#,
    )?;

    install(dir.path(), "opencode")?;
    let config = read(dir.path(), "opencode.json")?;
    assert!(
        config.contains("\"theme\""),
        "foreign top-level key dropped: {config}"
    );
    assert!(
        config.contains("\"edit\""),
        "foreign permission key dropped: {config}"
    );
    Ok(())
}

#[test]
fn reinstall_is_idempotent_for_every_adapter() -> TestResult {
    for agent in ["claude", "codex", "auggie", "opencode"] {
        let dir = repo()?;
        install(dir.path(), agent)?;
        let first: Vec<String> = portable_artifacts(agent)
            .iter()
            .map(|artifact| read(dir.path(), artifact))
            .collect::<Result<_, _>>()?;

        install(dir.path(), agent)?;
        let second: Vec<String> = portable_artifacts(agent)
            .iter()
            .map(|artifact| read(dir.path(), artifact))
            .collect::<Result<_, _>>()?;

        assert_eq!(
            first, second,
            "{agent}: re-running init changed generated content โ€” not idempotent"
        );
        for content in &second {
            assert!(
                content.matches("pushkin hook").count() <= 3,
                "{agent}: duplicated pushkin entries after re-run: {content}"
            );
        }
    }
    Ok(())
}

/// A config written by the OLD generator (absolute path in our own marked
/// entry) must be REPLACED, not duplicated beside the new one.
#[test]
fn stale_absolute_path_entry_is_replaced_not_duplicated() -> TestResult {
    let dir = repo()?;
    fs::create_dir_all(dir.path().join(".claude"))?;
    let stale = format!(
        r#"{{"hooks":{{"PreToolUse":[{{"_pushkin":"pushkin-v1","matcher":"Write|Edit|MultiEdit","hooks":[{{"type":"command","command":"{}/pushkin hook claude"}}]}}]}}}}"#,
        "/Users/someone/target/release"
    );
    fs::write(dir.path().join(".claude/settings.json"), stale)?;

    install(dir.path(), "claude")?;
    let settings = read(dir.path(), ".claude/settings.json")?;
    assert!(
        !settings.contains("/Users/someone"),
        "the stale absolute-path entry survived: {settings}"
    );
    assert_eq!(
        settings.matches("Write|Edit|MultiEdit").count(),
        1,
        "the stale entry was duplicated instead of replaced: {settings}"
    );
    Ok(())
}

#[test]
fn fresh_target_gets_the_full_generated_file() -> TestResult {
    let dir = repo()?;
    install(dir.path(), "claude")?;
    let settings = read(dir.path(), ".claude/settings.json")?;
    for needle in ["PreToolUse", "Stop", "SessionStart"] {
        assert!(
            settings.contains(needle),
            "fresh install must write the full pack โ€” {needle} missing: {settings}"
        );
    }
    Ok(())
}