pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! The agent adapter registry (addendum §4): payload normalization into one
//! `ToolAction`, and per-agent verdict encoding. One brain, five mouths —
//! adapters translate, never decide (spec §6.1).

use anyhow::{bail, Result};
use pushkin_core::edits::Replacement;
use serde_json::Value;

pub mod families;
mod shared;

use families::{
    normalize_auggie, normalize_claude_family, normalize_codex, normalize_hermes,
    normalize_opencode,
};

// Re-exported for check.rs, which builds edits from a Claude payload directly
// (the check verb shares the reconstruction, not the normalizer).
pub(crate) use families::claude_replacements;

pub const AGENTS: &[&str] = &["claude", "codex", "auggie", "hermes", "opencode"];

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Agent {
    Claude,
    Codex,
    Auggie,
    Hermes,
    Opencode,
}

impl Agent {
    /// Every adapter, in registry order — doctor dispatches over this
    /// exhaustively so a new agent cannot be forgotten silently.
    pub const ALL: [Agent; 5] = [
        Agent::Claude,
        Agent::Codex,
        Agent::Auggie,
        Agent::Hermes,
        Agent::Opencode,
    ];

    /// Resolves an agent name, rejecting unknowns with near-miss candidates
    /// (design principle: ambiguity is an error with suggestions, spec §7.1).
    pub fn parse(name: &str) -> Result<Self> {
        match name {
            "claude" => Ok(Agent::Claude),
            "codex" => Ok(Agent::Codex),
            "auggie" => Ok(Agent::Auggie),
            "hermes" => Ok(Agent::Hermes),
            "opencode" => Ok(Agent::Opencode),
            unknown => {
                let candidates = nearest_agents(unknown).join(", ");
                bail!("unknown agent '{unknown}'; did you mean: {candidates}?")
            }
        }
    }

    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Agent::Claude => "claude",
            Agent::Codex => "codex",
            Agent::Auggie => "auggie",
            Agent::Hermes => "hermes",
            Agent::Opencode => "opencode",
        }
    }
}

/// One file write extracted from a tool call.
#[derive(Debug, Clone)]
pub struct FileWrite {
    pub path: String,
    pub content: String,
    /// F48 Phase B — the edit operations, for a mutation that carries them
    /// instead of content. Empty for a `Write`, and empty for any family whose
    /// Phase B arm has not landed: an empty list means "no reconstruction is
    /// possible", which routes to Phase A's interim refusal.
    pub edits: Vec<Replacement>,
}

/// SPIKE — what the agent is trying to DO with the paths in a
/// `ToolAction`. Writes carry content and meet the contract pipeline;
/// reads carry a range instead and meet the read contract only.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Intent {
    Write,
    /// An unbounded whole-file read — no `offset`/`limit` in the payload.
    ReadWhole,
    /// A read naming an explicit range: the deliberate shape, and the one
    /// that satisfies a host's read-before-edit requirement.
    ReadRange,
    /// A shell invocation. Its target is not a declared path but text inside
    /// a command string, so it is matched by substring against the gated
    /// globs rather than parsed — see `gate_shell_read`.
    Shell,
    /// F48 Phase A — a MUTATION that names a target but carries no content:
    /// `Edit`'s `old_string`/`new_string`, `MultiEdit`'s `edits[]`. It is a
    /// write in every sense that matters to a path rule, and unevaluable by
    /// every rule that needs bytes. Recognizing it is the whole fix: it used
    /// to be classified malformed, and the malformed fallback consults only
    /// `is_protected`, so `read_only_paths` and mapped-contract rules fell
    /// through to a silent allow on the two tools agents use most.
    ///
    /// Carries the tool's name so the refusal can say which one it refused —
    /// a `&'static str` from a fixed set, which keeps `Intent` `Copy`.
    MutateNoContent(&'static str),
    /// F58 — a DELETE. Decidable by the path rules and by nothing else: there
    /// is no content now and there never will be, so unlike `MutateNoContent`
    /// it must NOT refuse under `content_unavailable`. That rule says a content
    /// requirement could not be EVALUATED; for a file that is going away, no
    /// content requirement applies at all.
    Delete,
}

/// The normalized action every adapter feeds the pipeline (addendum §4).
/// `files` is usually one entry; Codex `apply_patch` may carry several.
#[derive(Debug, Clone)]
pub struct ToolAction {
    pub session: String,
    pub files: Vec<FileWrite>,
    pub is_stop: bool,
    pub intent: Intent,
    /// The raw shell command, present only for `Intent::Shell`. Carried
    /// verbatim and never parsed: a shell grammar is an adversarial surface,
    /// and a wrong parse either blocks real work or admits a crafted command.
    pub command: Option<String>,
}

/// Parse outcome mirroring the check-verb contract: `Err(Some(path))` is a
/// partial parse that still revealed a target (fail-closed candidate).
pub type ParseOutcome = Result<ToolAction, Option<String>>;

/// Normalizes one agent-native hook payload into a `ToolAction`.
pub fn normalize(agent: Agent, raw: &str) -> ParseOutcome {
    let value: Value = serde_json::from_str(raw).map_err(|_| None)?;
    match agent {
        Agent::Claude => normalize_claude_family(&value),
        Agent::Codex => normalize_codex(&value),
        Agent::Auggie => normalize_auggie(&value),
        Agent::Hermes => normalize_hermes(&value),
        Agent::Opencode => normalize_opencode(&value),
    }
}

fn nearest_agents(unknown: &str) -> Vec<&'static str> {
    let mut scored: Vec<(usize, &'static str)> = AGENTS
        .iter()
        .map(|&agent| (levenshtein(unknown, agent), agent))
        .collect();
    scored.sort_unstable();
    scored.truncate(2);
    scored.into_iter().map(|(_, agent)| agent).collect()
}

fn levenshtein(a: &str, b: &str) -> usize {
    let a_chars: Vec<char> = a.chars().collect();
    let b_chars: Vec<char> = b.chars().collect();
    let mut previous: Vec<usize> = (0..=b_chars.len()).collect();
    let mut current = vec![0usize; b_chars.len() + 1];
    for (i, &a_char) in a_chars.iter().enumerate() {
        current[0] = i + 1;
        for (j, &b_char) in b_chars.iter().enumerate() {
            let substitution = usize::from(a_char != b_char);
            current[j + 1] = (previous[j] + substitution)
                .min(previous[j + 1] + 1)
                .min(current[j] + 1);
        }
        std::mem::swap(&mut previous, &mut current);
    }
    previous[b_chars.len()]
}