pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Remediation (lefthook floor) L3/L4: generator portability + coexistence,
//! and doctor coverage for the floor.
//!
//! L3 field evidence, both 2026-08-14:
//!   - the emitted command embedded `std::env::current_exe()`, so the
//!     generated floor broke on any other clone and after `cargo clean`;
//!   - installing into a second repo (sec-skills) would have CLOBBERED an
//!     existing `lefthook.yml` carrying the team's ruff pre-commit command,
//!     because `install_lefthook` was a blind `fs::write`.
//!
//! The fix emits the spec §17 command verbatim (`pushkin check --staged
//! --json`, PATH-resolved) and MERGES a marker-bracketed block into an
//! existing file — the AGENTS.md managed-block precedent — never touching
//! foreign keys.
//!
//! 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"]
"#;

/// A lefthook config as a team that already uses lefthook has it: a ruff
/// pre-commit command pushkin must never touch (the sec-skills shape).
const FOREIGN_LEFTHOOK: &str = "pre-commit:\n  \
    parallel: true\n  \
    commands:\n    \
      ruff:\n      \
        glob: \"*.py\"\n      \
        run: uv run ruff check {staged_files}\n";

const FLOOR: &str = "lefthook.yml";

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

fn run(dir: &Path, args: &[&str]) -> Result<(Option<i32>, String), Box<dyn std::error::Error>> {
    let output = Command::cargo_bin("pushkin")?
        .current_dir(dir)
        .args(args)
        .output()?;
    Ok((
        output.status.code(),
        format!(
            "{}{}",
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        ),
    ))
}

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

/// A floor as the OLD binary generated it: absolute path + Stop payload.
const STALE_V1_FLOOR: &str = "# GENERATED by pushkin init — do not hand-edit. marker: pushkin-v1\n\
     pre-commit:\n  commands:\n    pushkin:\n      \
     run: echo '{\"stop_hook_active\":false}' | /Users/someone/target/release/pushkin check\n";

// ---------- L3(a): portability — the spec §17 command, no absolute path ----------

#[test]
fn fresh_repo_gets_the_spec_command_with_no_absolute_path() -> TestResult {
    let dir = repo()?;
    let (code, _) = run(dir.path(), &["init", "--agent", "lefthook"])?;
    assert_eq!(code, Some(0));

    let content = read_floor(dir.path())?;
    assert!(
        content.contains("pushkin check --staged --json"),
        "spec §17 command emitted verbatim: {content}"
    );
    assert!(
        !content.contains('/'),
        "no absolute path may be baked into the generated floor: {content}"
    );
    assert!(
        !content.contains("stop_hook_active"),
        "the echo'd Stop payload pattern is retired: {content}"
    );
    Ok(())
}

#[test]
fn generated_floor_carries_the_v3_marker() -> TestResult {
    let dir = repo()?;
    run(dir.path(), &["init", "--agent", "lefthook"])?;

    let content = read_floor(dir.path())?;
    // §1.4 per-edit override (N13 fail-open pass, B1), Authorization quote 1:
    // the marker bump is the decided surface change, and this assertion pinned
    // v2 by construction. v3 marks the pre-guard floor as detectable, exactly
    // as v2 marked the absolute-path/Stop-sweep floor.
    assert!(
        content.contains("pushkin-v3"),
        "marker bumped so the v2 (pre-guard) format is detectable: {content}"
    );
    Ok(())
}

// ---------- L3(b): merge, never overwrite ----------

#[test]
fn existing_foreign_config_is_preserved_and_pushkin_block_added() -> TestResult {
    // Field evidence (sec-skills, 2026-08-14): a blind fs::write here
    // destroyed the team's ruff hook.
    let dir = repo()?;
    fs::write(dir.path().join(FLOOR), FOREIGN_LEFTHOOK)?;

    let (code, _) = run(dir.path(), &["init", "--agent", "lefthook"])?;
    assert_eq!(code, Some(0));

    let content = read_floor(dir.path())?;
    assert!(
        content.contains("ruff:") && content.contains("uv run ruff check"),
        "the foreign ruff command must survive install: {content}"
    );
    assert!(
        content.contains("parallel: true"),
        "foreign keys are untouched: {content}"
    );
    assert!(
        content.contains("pushkin check --staged --json"),
        "the pushkin block is added alongside: {content}"
    );
    Ok(())
}

#[test]
fn reinstall_is_idempotent_and_replaces_only_our_block() -> TestResult {
    let dir = repo()?;
    fs::write(dir.path().join(FLOOR), FOREIGN_LEFTHOOK)?;

    run(dir.path(), &["init", "--agent", "lefthook"])?;
    let first = read_floor(dir.path())?;
    run(dir.path(), &["init", "--agent", "lefthook"])?;
    let second = read_floor(dir.path())?;

    assert_eq!(
        first, second,
        "re-running init must be a no-op on an already-current floor"
    );
    assert_eq!(
        second.matches("pushkin check --staged --json").count(),
        1,
        "our command appears exactly once — no stacking: {second}"
    );
    assert!(
        second.contains("uv run ruff check"),
        "the foreign command survives re-run too: {second}"
    );
    Ok(())
}

#[test]
fn a_stale_v1_block_is_replaced_not_duplicated() -> TestResult {
    // The upgrade path: a floor generated by the OLD binary, carrying the
    // absolute path and the Stop payload, must be rewritten in place.
    let dir = repo()?;
    fs::write(dir.path().join(FLOOR), STALE_V1_FLOOR)?;

    run(dir.path(), &["init", "--agent", "lefthook"])?;

    let content = read_floor(dir.path())?;
    assert!(
        !content.contains("/Users/someone"),
        "the stale absolute path must be gone: {content}"
    );
    assert!(
        !content.contains("stop_hook_active"),
        "the stale Stop payload must be gone: {content}"
    );
    assert!(
        content.contains("pushkin check --staged --json"),
        "replaced by the spec command: {content}"
    );
    Ok(())
}

#[test]
fn remove_agent_strips_our_block_and_keeps_foreign_commands() -> TestResult {
    let dir = repo()?;
    fs::write(dir.path().join(FLOOR), FOREIGN_LEFTHOOK)?;
    run(dir.path(), &["init", "--agent", "lefthook"])?;

    let (code, _) = run(dir.path(), &["init", "--remove-agent", "lefthook"])?;
    assert_eq!(code, Some(0));

    let content = read_floor(dir.path())?;
    assert!(
        content.contains("uv run ruff check"),
        "uninstall must not take the team's hooks with it: {content}"
    );
    assert!(
        !content.contains("pushkin check --staged --json"),
        "our command is gone: {content}"
    );
    Ok(())
}

#[test]
fn remove_agent_deletes_a_file_that_is_only_ours() -> TestResult {
    // Nothing foreign in it → the whole generated file goes.
    let dir = repo()?;
    run(dir.path(), &["init", "--agent", "lefthook"])?;
    run(dir.path(), &["init", "--remove-agent", "lefthook"])?;

    assert!(
        !dir.path().join(FLOOR).exists(),
        "a purely generated floor is removed entirely"
    );
    Ok(())
}

// ---------- L4: doctor coverage ----------

#[test]
fn doctor_flags_a_stale_v1_floor() -> TestResult {
    let dir = repo()?;
    fs::write(dir.path().join(FLOOR), STALE_V1_FLOOR)?;

    let (code, output) = run(dir.path(), &["doctor"])?;
    assert_eq!(code, Some(1), "a stale floor is a red finding: {output}");
    assert!(
        output.contains("lefthook"),
        "the finding names the floor: {output}"
    );
    assert!(
        output.contains("stale"),
        "the finding says what is wrong: {output}"
    );
    Ok(())
}

#[test]
fn doctor_repair_regenerates_a_stale_floor() -> TestResult {
    let dir = repo()?;
    fs::write(dir.path().join(FLOOR), STALE_V1_FLOOR)?;

    run(dir.path(), &["doctor", "--repair"])?;

    let content = read_floor(dir.path())?;
    // §1.4 per-edit override (N13 fail-open pass, B1), Authorization quote 2:
    // repair now regenerates the v3 guard, so the expectation moves with it.
    // The merge-path property this test exists to prove is unchanged — the
    // regenerated block still carries the spec §17 command, now reached
    // through the guard's provisioned branch.
    assert!(
        content.contains("exec pushkin check --staged --json") && content.contains("pushkin-v3"),
        "repair regenerates through the merge path: {content}"
    );
    Ok(())
}

#[test]
fn doctor_is_quiet_when_no_floor_is_installed() -> TestResult {
    // Presence-keyed detection (§13): an absent pack is never a finding.
    let dir = repo()?;
    let (_, output) = run(dir.path(), &["doctor"])?;
    assert!(
        !output.contains("lefthook.yml is stale"),
        "no floor installed → no stale finding: {output}"
    );
    Ok(())
}

#[test]
fn doctor_names_an_unresolvable_pushkin_binary() -> TestResult {
    // L4(b): the floor calls a PATH-resolved `pushkin`. If it is not on
    // PATH the hook silently never fires, so doctor must say so.
    let dir = repo()?;
    run(dir.path(), &["init", "--agent", "lefthook"])?;

    let output = Command::cargo_bin("pushkin")?
        .current_dir(dir.path())
        .arg("doctor")
        .env("PATH", "")
        .output()?;
    let text = format!(
        "{}{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    assert!(
        text.contains("PATH"),
        "the finding names the PATH problem: {text}"
    );
    assert!(
        text.contains("install") || text.contains("cargo install"),
        "the finding carries an actionable hint: {text}"
    );
    Ok(())
}