pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Pre-distribution hygiene H2 (PR #24 exit review, R2 + the two PHASE-LOG
//! observations carried without action): the lefthook file is handled
//! structure-aware, not line-shape-blind.
//!
//! (a) Pushkin comment shapes quoted INSIDE a foreign block scalar are the
//!     user's content: never a scrub target, never a block boundary.
//! (b) The spliced block is indented relative to the config's own
//!     `commands:` entries, not hardcoded to the two-space house style.
//! (c) `--remove-agent lefthook` keeps a file that carries the user's own
//!     comments — only pushkin-generated comment scaffolding counts as ours.
//!
//! Fixture note (PR #23 Addendum 2 §5(a) precedent): no non-claude adapter
//! pack is installed, so the known `doctor --repair` exit-1 deferral bug
//! (IDEAS.md:20) cannot confound assertions here.
//!
//! Committed first (RED), read-only hereafter (N10).

use assert_cmd::Command;
use std::fs;
use std::path::Path;
use std::process::Command as StdCommand;

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

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

/// The executed R2 probe, pinned: a foreign command whose block scalar
/// quotes pushkin's generated-file header verbatim, beside a real v2 block.
const FOREIGN_SCALAR_QUOTING_HEADER: &str = "pre-commit:\n  commands:\n    deploy:\n      \
    run: |\n        echo one\n        \
    # GENERATED by pushkin init — a foreign script quoting our header\n        \
    echo two\n  \
    # pushkin:begin pushkin-v2 — GENERATED by pushkin init; do not hand-edit\n    \
    pushkin:\n      run: pushkin check --staged --json\n  \
    # pushkin:end pushkin-v2\n";

/// Marker-shaped text living entirely INSIDE a foreign scalar — there is no
/// pushkin block in this file at all.
const FOREIGN_SCALAR_QUOTING_MARKERS: &str = "pre-commit:\n  commands:\n    deploy:\n      \
    run: |\n        echo before\n        \
    # pushkin:begin pushkin-v2 — GENERATED by pushkin init; do not hand-edit\n        \
    echo inside\n        \
    # pushkin:end pushkin-v2\n        \
    echo after\n";

/// A team's config in a four-space indent style: `commands:` at 4, entries
/// at 8.
const WIDE_INDENT_FOREIGN: &str = "pre-commit:\n    commands:\n        ruff:\n            \
    run: uv run ruff check {staged_files}\n";

/// A user file that is comments plus bare scaffolding — the user's notes,
/// not pushkin's.
const COMMENTS_ONLY_USER_FILE: &str = "# team notes\n# more notes\npre-commit:\n  commands:\n";

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 binary_dir() -> Result<String, Box<dyn std::error::Error>> {
    Ok(Path::new(env!("CARGO_BIN_EXE_pushkin"))
        .parent()
        .ok_or("binary under test has no parent")?
        .to_string_lossy()
        .into_owned())
}

/// Runs pushkin with PATH set explicitly so `pushkin` IS resolvable — never
/// inherited, so the resolvability finding cannot fire spuriously.
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("PATH", binary_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 read_floor(dir: &Path) -> Result<String, Box<dyn std::error::Error>> {
    Ok(fs::read_to_string(dir.join(FLOOR))?)
}

/// Leading-space count of the first line whose trimmed form is exactly
/// `key` — an entry line, never a comment that merely mentions it.
fn entry_indent(content: &str, key: &str) -> Option<usize> {
    content
        .lines()
        .find(|line| line.trim() == key)
        .map(|line| line.len() - line.trim_start().len())
}

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

    let (code, output) = run(dir.path(), &["init", "--agent", "lefthook"])?;
    assert_eq!(code, Some(0), "upgrade must succeed: {output}");

    let content = read_floor(dir.path())?;
    assert!(
        content.contains("a foreign script quoting our header"),
        "the quoted line inside the foreign scalar is the user's content \
         and must survive byte-for-byte: {content}"
    );
    assert!(
        content.contains("echo one") && content.contains("echo two"),
        "the foreign scalar body is untouched: {content}"
    );
    assert_eq!(
        content.matches("# pushkin:begin").count(),
        1,
        "the real v2 block was replaced by exactly one current block: {content}"
    );
    assert!(
        !content.contains("pushkin-v2"),
        "the real v2 markers are gone; only current markers remain: {content}"
    );
    Ok(())
}

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

    let (code, output) = run(dir.path(), &["init", "--agent", "lefthook"])?;
    assert_eq!(code, Some(0), "install must succeed: {output}");

    let content = read_floor(dir.path())?;
    assert!(
        content.contains("echo before")
            && content.contains("echo inside")
            && content.contains("echo after"),
        "the foreign scalar must survive intact — marker-shaped text inside \
         it is not a pushkin block: {content}"
    );
    assert!(
        content.contains("pushkin:begin pushkin-v2") && content.contains("pushkin:end pushkin-v2"),
        "the quoted marker lines are the user's content and must survive: {content}"
    );
    assert!(
        content.contains("pushkin-v3") && content.contains("command -v pushkin"),
        "our block is installed alongside, not spliced into the scalar: {content}"
    );
    Ok(())
}

/// `lefthook` is a dev-environment tool; skip rather than fail where it is
/// absent so the suite stays honest on machines without it.
fn lefthook_available() -> bool {
    StdCommand::new("lefthook")
        .arg("version")
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .is_ok_and(|status| status.success())
}

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

    let (code, output) = run(dir.path(), &["init", "--agent", "lefthook"])?;
    assert_eq!(code, Some(0), "install must succeed: {output}");

    let content = read_floor(dir.path())?;
    let ruff = entry_indent(&content, "ruff:").ok_or("ruff entry missing")?;
    let pushkin = entry_indent(&content, "pushkin:").ok_or("pushkin entry missing")?;
    assert_eq!(
        pushkin, ruff,
        "our entry must sit at the same indent as its siblings: {content}"
    );

    if lefthook_available() {
        let git = StdCommand::new("git")
            .current_dir(dir.path())
            .args(["init", "-q", "."])
            .status()?;
        assert!(git.success(), "fixture git init must succeed");
        let validate = StdCommand::new("lefthook")
            .current_dir(dir.path())
            .arg("validate")
            .output()?;
        assert!(
            validate.status.success(),
            "real `lefthook validate` must accept the wide-indent merge: {}{}",
            String::from_utf8_lossy(&validate.stdout),
            String::from_utf8_lossy(&validate.stderr)
        );
    }
    Ok(())
}

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

    let (code, output) = run(dir.path(), &["init", "--remove-agent", "lefthook"])?;
    assert_eq!(code, Some(0), "remove must succeed: {output}");

    assert!(
        dir.path().join(FLOOR).exists(),
        "a file carrying the user's own comments is not pushkin's alone \
         and must not be deleted"
    );
    let content = read_floor(dir.path())?;
    assert!(
        content.contains("# team notes") && content.contains("# more notes"),
        "the user's comments survive removal: {content}"
    );
    assert!(
        !content.contains("pushkin"),
        "no pushkin trace survives removal: {content}"
    );
    Ok(())
}