pushkin 0.1.0

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! `pushkin check`: stdin hook payload → gate decision. Exit 0 allow,
//! exit 2 block (+ retry prompt on stderr). A Stop payload (no `tool_input`)
//! sweeps every mapped file in the repo (spec §8.1: cheap per-edit,
//! expensive on Stop). Fail-open on malformed input is LOGGED as an event;
//! a partial parse that reveals a protected-path target fails CLOSED
//! (review directives, Phase 1 plan).
//!
//! `--staged` (spec §12) gates the git INDEX instead of reading stdin —
//! the pre-commit floor's scope: only files this commit actually ships.
//! `--json` swaps the prose retry prompt for the §8.3 envelope on stdout.

use anyhow::Result;
use pushkin_core::envelope::{CheckResult, Decision, Violation};
use pushkin_core::events::EventLog;
use pushkin_core::manifest::Manifest;
use pushkin_core::pipeline::{check_write, WriteRequest};
use std::io::Read;

use super::{apply_waivers, events_db_path, load_manifest};

/// How the verdict is rendered: prose retry prompt (the agent-facing
/// default) or the uniform §8.3 envelope as JSON (spec §17's floor).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Output {
    Prose,
    Json,
}

impl Output {
    fn from_flag(json: bool) -> Self {
        if json {
            Output::Json
        } else {
            Output::Prose
        }
    }
}

pub fn run(staged: bool, json: bool) -> Result<i32> {
    let output = Output::from_flag(json);
    let manifest = load_manifest()?;
    let log = EventLog::open(events_db_path()?)?;
    let session = log.begin_session()?;

    // --staged never touches stdin: git is the input (spec §12).
    if staged {
        let result = apply_waivers(check_staged(&manifest)?);
        log.append(&session, &result)?;
        return Ok(emit(&result, output));
    }

    let mut raw = String::new();
    std::io::stdin().read_to_string(&mut raw)?;

    let request = match parse_payload(&raw) {
        Ok(Payload::Write(request)) => request,
        Ok(Payload::Stop) => {
            let result = sweep_repo(&manifest)?;
            log.append(&session, &result)?;
            return Ok(emit(&result, output));
        }
        Err(partial) => {
            // Partial parse revealing a protected-path target → fail CLOSED.
            if let Some(file_path) = partial {
                if manifest.is_protected(&file_path) {
                    let result = check_write(
                        &manifest,
                        &WriteRequest {
                            file_path,
                            content: String::new(),
                        },
                    );
                    log.append(&session, &result)?;
                    return Ok(emit(&result, output));
                }
            }
            // Otherwise fail open — loudly, and on the record.
            log.append_failopen(&session, "malformed hook payload")?;
            eprintln!(
                "pushkin: unrecognized hook payload, failing open (event logged; \
                 run `pushkin doctor` if this repeats)"
            );
            return Ok(0);
        }
    };

    let result = check_write(&manifest, &request);
    let result = apply_waivers(super::gate_read_only(&manifest, result, &request.file_path));
    log.append(&session, &result)?;
    Ok(emit(&result, output))
}

/// Renders the verdict in the requested shape and returns the exit code:
/// 0 allow, 2 block. JSON goes to stdout (machine-readable, spec §17);
/// prose stays on stderr, where the agent-facing retry prompt has always
/// been.
fn emit(result: &CheckResult, output: Output) -> i32 {
    match output {
        Output::Json => println!(
            "{}",
            serde_json::to_string_pretty(result).unwrap_or_else(|_| {
                // Unreachable — `CheckResult` is plain derived data that
                // `serde_json` cannot fail to serialize — but a fallback
                // that ever fired must fail loud: deny-shaped by
                // construction, so a serialization bug can never launder a
                // blocking verdict into an allow-shaped envelope (M3).
                r#"{"decision":"block","violations":[],"durationMs":0.0}"#.to_owned()
            })
        ),
        Output::Prose => {
            if !result.violations.is_empty() {
                eprintln!("{}", render(result));
            }
        }
    }
    if result.violations.is_empty() {
        0
    } else {
        2
    }
}

/// The pre-commit floor's pass (spec §12, §17): every staged path with a
/// manifest mapping, gated on its INDEX content — `git show :<path>`, not
/// the working tree, because the commit ships the index. Files the author
/// never touched are out of scope by construction.
fn check_staged(manifest: &Manifest) -> Result<CheckResult> {
    let started = std::time::Instant::now();
    let mut violations: Vec<Violation> = Vec::new();
    for file_path in super::git::staged_files()? {
        // Protected surface staged: an ADVISORY, never a deny (S2d). The
        // floor runs for humans too, and a human commit of the manifest
        // is legitimate — the write path already denies agents at edit
        // time (and cannot see Bash-side writes; that boundary is the
        // advisory's reason to be loud). The notice stays on stderr so a
        // --json envelope on stdout is unchanged.
        if manifest.is_protected(&file_path) || file_path.starts_with("pushkin/") {
            eprintln!(
                "pushkin: note — protected surface staged ({file_path}); the write \
                 gate denies agent edits here and this floor does not block commits. \
                 A human should own this change."
            );
        }
        // Read-only paths are checked BEFORE the mapping skip: committed
        // test suites have no contract mapping, and the pre-commit floor
        // is exactly where an agent edit to one must be caught. A staged
        // NEW file is not in HEAD and passes.
        if manifest.is_read_only(&file_path) && super::committed_in_head(&file_path) {
            violations.push(pushkin_core::pipeline::read_only_violation(&file_path));
            continue;
        }
        if manifest.mapping_for(&file_path).is_none() {
            continue;
        }
        let Some(content) = super::git::staged_content(&file_path)? else {
            continue;
        };
        violations.extend(check_write(manifest, &WriteRequest { file_path, content }).violations);
    }
    Ok(CheckResult {
        decision: if violations.is_empty() {
            Decision::Allow
        } else {
            Decision::Block
        },
        violations,
        duration_ms: started.elapsed().as_secs_f64() * 1000.0,
    })
}

enum Payload {
    Write(WriteRequest),
    Stop,
}

/// `Ok(Write)` on a well-formed `PreToolUse` payload; `Ok(Stop)` on a
/// Stop-event payload (no `tool_input`); Err(Some(path)) when malformed but a file-path
/// target was recoverable; Err(None) when nothing was.
fn parse_payload(raw: &str) -> Result<Payload, Option<String>> {
    let value: serde_json::Value = serde_json::from_str(raw).map_err(|_| None)?;
    let Some(tool_input) = value.get("tool_input") else {
        // Claude's Stop hook payload carries `stop_hook_active`, never `tool_input`.
        if value.get("stop_hook_active").is_some() {
            return Ok(Payload::Stop);
        }
        return Err(None);
    };
    let file_path = tool_input
        .get("file_path")
        .and_then(serde_json::Value::as_str)
        .ok_or(None)?
        .to_owned();

    let well_formed = value
        .get("tool_name")
        .and_then(serde_json::Value::as_str)
        .is_some();
    let content = tool_input
        .get("content")
        .and_then(serde_json::Value::as_str);
    match (well_formed, content) {
        (true, Some(content)) => Ok(Payload::Write(WriteRequest {
            file_path,
            content: content.to_owned(),
        })),
        _ => Err(Some(file_path)),
    }
}

/// The expensive whole-repo pass: every mapped file re-checked with the same
/// core the per-write gate uses. Shared with the hook verb's Stop handling.
pub fn sweep_repo(manifest: &Manifest) -> Result<CheckResult> {
    let started = std::time::Instant::now();
    let mut violations: Vec<Violation> = Vec::new();
    for file_path in walk_repo(std::path::Path::new("."))? {
        if manifest.mapping_for(&file_path).is_none() {
            continue;
        }
        let content = std::fs::read_to_string(&file_path)?;
        violations.extend(check_write(manifest, &WriteRequest { file_path, content }).violations);
    }
    Ok(CheckResult {
        decision: if violations.is_empty() {
            Decision::Allow
        } else {
            Decision::Block
        },
        violations,
        duration_ms: started.elapsed().as_secs_f64() * 1000.0,
    })
}

const IGNORED_DIRS: &[&str] = &["node_modules", ".git", ".pushkin", ".scout", "target"];

fn walk_repo(root: &std::path::Path) -> Result<Vec<String>> {
    let mut files = Vec::new();
    let mut pending = vec![root.to_path_buf()];
    while let Some(dir) = pending.pop() {
        for entry in std::fs::read_dir(&dir)? {
            let entry = entry?;
            let path = entry.path();
            let name = entry.file_name().to_string_lossy().into_owned();
            if path.is_dir() {
                if !IGNORED_DIRS.contains(&name.as_str()) {
                    pending.push(path);
                }
            } else if let Ok(relative) = path.strip_prefix(root) {
                files.push(relative.to_string_lossy().into_owned());
            }
        }
    }
    files.sort();
    Ok(files)
}

fn render(result: &CheckResult) -> String {
    let mut lines = vec!["pushkin: write blocked. Fix the violations below and retry.".to_owned()];
    for violation in &result.violations {
        let contract = violation
            .contract
            .as_deref()
            .map(|name| format!(" (contract: {name})"))
            .unwrap_or_default();
        lines.push(format!(
            "  {}:{} [{}]{contract}",
            violation.file, violation.line, violation.rule
        ));
        lines.push(format!("    fix: {}", violation.fix_hint));
        for suggestion in &violation.suggestions {
            lines.push(format!("    try: {suggestion}"));
        }
    }
    lines.join("\n")
}