pushkin 0.1.0

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 serde_json::Value;

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,
}

/// 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,
}

/// 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),
    }
}

/// Claude, Codex, and Auggie share the Claude hook payload shape
/// (addendum §3.2/§3.4: `tool_name` + `tool_input.file_path`/`content`).
fn normalize_claude_family(value: &Value) -> ParseOutcome {
    let session = session_from(value, "session_id");
    let Some(tool_input) = value.get("tool_input") else {
        if value.get("stop_hook_active").is_some() {
            return Ok(ToolAction {
                session,
                files: vec![],
                is_stop: true,
            });
        }
        return Err(None);
    };
    let path = tool_input
        .get("file_path")
        .and_then(Value::as_str)
        .map(relativize)
        .ok_or(None)?;
    let well_formed = value.get("tool_name").and_then(Value::as_str).is_some();
    let content = tool_input.get("content").and_then(Value::as_str);
    match (well_formed, content) {
        (true, Some(content)) => Ok(ToolAction {
            session,
            files: vec![FileWrite {
                path,
                content: content.to_owned(),
            }],
            is_stop: false,
        }),
        _ => Err(Some(path)),
    }
}

/// Codex `PreToolUse` (learn.chatgpt.com/docs/hooks): `apply_patch` carries
/// the patch text in `tool_input.command`; each `*** Add File:` /
/// `*** Update File:` section is one gated write. The recorded-payload
/// shape (`tool_input.file_path`/`content`) is accepted first for
/// conformance-suite compatibility.
fn normalize_codex(value: &Value) -> ParseOutcome {
    if value
        .get("tool_input")
        .and_then(|input| input.get("file_path"))
        .is_some()
    {
        return normalize_claude_family(value);
    }
    let session = session_from(value, "session_id");
    let Some(tool_input) = value.get("tool_input") else {
        if value.get("stop_hook_active").is_some() {
            return Ok(ToolAction {
                session,
                files: vec![],
                is_stop: true,
            });
        }
        return Err(None);
    };
    let command = tool_input
        .get("command")
        .and_then(Value::as_str)
        .ok_or(None)?;
    let files = parse_apply_patch(command);
    if files.is_empty() {
        // A patch we cannot parse still names no target: fail-open path.
        return Err(None);
    }
    Ok(ToolAction {
        session,
        files,
        is_stop: false,
    })
}

/// Parses (path, added-content) pairs from a Codex `apply_patch` command string.
fn parse_apply_patch(command: &str) -> Vec<FileWrite> {
    let mut files = Vec::new();
    let mut current_path: Option<String> = None;
    let mut current_content = String::new();
    for line in command.lines() {
        let file_marker = line
            .strip_prefix("*** Add File: ")
            .or_else(|| line.strip_prefix("*** Update File: "));
        if let Some(path) = file_marker {
            if let Some(done) = current_path.take() {
                files.push(FileWrite {
                    path: done,
                    content: std::mem::take(&mut current_content),
                });
            }
            current_path = Some(relativize(path.trim()));
        } else if line.starts_with("*** End Patch") {
            if let Some(done) = current_path.take() {
                files.push(FileWrite {
                    path: done,
                    content: std::mem::take(&mut current_content),
                });
            }
        } else if current_path.is_some() {
            if let Some(added) = line.strip_prefix('+') {
                current_content.push_str(added);
                current_content.push('\n');
            }
        }
    }
    if let Some(done) = current_path.take() {
        files.push(FileWrite {
            path: done,
            content: current_content,
        });
    }
    files
}

/// Auggie `PreToolUse` (live-captured 0.35.0, docs.augmentcode.com/cli/hooks):
/// `conversation_id` + `tool_name` + `tool_input.path`/`file_content`
/// (`save-file`) or `tool_input.path` + edit fields (`str-replace-editor`).
fn normalize_auggie(value: &Value) -> ParseOutcome {
    let session = session_from(value, "conversation_id");
    let Some(tool_input) = value.get("tool_input") else {
        return Err(None);
    };
    let path = tool_input
        .get("path")
        .or_else(|| tool_input.get("file_path"))
        .and_then(Value::as_str)
        .map(relativize)
        .ok_or(None)?;
    let well_formed = value.get("tool_name").and_then(Value::as_str).is_some();
    let content = tool_input
        .get("file_content")
        .or_else(|| tool_input.get("new_str_1"))
        .or_else(|| tool_input.get("content"))
        .and_then(Value::as_str);
    match (well_formed, content) {
        (true, Some(content)) => Ok(ToolAction {
            session,
            files: vec![FileWrite {
                path,
                content: content.to_owned(),
            }],
            is_stop: false,
        }),
        _ => Err(Some(path)),
    }
}

/// Hermes `pre_tool_call`: `tool_name` + `tool_input.path`/`content` (addendum §3.5).
fn normalize_hermes(value: &Value) -> ParseOutcome {
    let session = session_from(value, "session_id");
    let Some(tool_input) = value.get("tool_input") else {
        return Err(None);
    };
    let path = tool_input
        .get("path")
        .or_else(|| tool_input.get("file_path"))
        .and_then(Value::as_str)
        .map(relativize)
        .ok_or(None)?;
    let well_formed = value.get("tool_name").and_then(Value::as_str).is_some();
    let content = tool_input.get("content").and_then(Value::as_str);
    match (well_formed, content) {
        (true, Some(content)) => Ok(ToolAction {
            session,
            files: vec![FileWrite {
                path,
                content: content.to_owned(),
            }],
            is_stop: false,
        }),
        _ => Err(Some(path)),
    }
}

/// opencode plugin relay: `sessionID` + `tool` + `args.filePath`/`content`
/// (addendum §3.3 — the TS plugin forwards its hook input verbatim).
/// opencode hands the tool an ABSOLUTE `filePath`; manifest globs are
/// repo-relative, so relativize against the working directory (the plugin
/// sets `cwd` to the repo root), tolerating macOS's /tmp → /private/tmp.
fn normalize_opencode(value: &Value) -> ParseOutcome {
    let session = session_from(value, "sessionID");
    let Some(args) = value.get("args") else {
        return Err(None);
    };
    let path = args
        .get("filePath")
        .and_then(Value::as_str)
        .map(relativize)
        .ok_or(None)?;
    let well_formed = value.get("tool").and_then(Value::as_str).is_some();
    let content = args.get("content").and_then(Value::as_str);
    match (well_formed, content) {
        (true, Some(content)) => Ok(ToolAction {
            session,
            files: vec![FileWrite {
                path,
                content: content.to_owned(),
            }],
            is_stop: false,
        }),
        _ => Err(Some(path)),
    }
}

/// Strips the current working directory (raw and canonicalized) from an
/// absolute path; relative paths pass through unchanged. Applied in every
/// normalizer: agents freely mix absolute and repo-relative paths (live
/// finding: Hermes retried a blocked write with the absolute path and the
/// glob missed it).
fn relativize(path: &str) -> String {
    let candidate = std::path::Path::new(path);
    if candidate.is_relative() {
        return path.to_owned();
    }
    let Ok(cwd) = std::env::current_dir() else {
        return path.to_owned();
    };
    if let Ok(stripped) = candidate.strip_prefix(&cwd) {
        return stripped.to_string_lossy().into_owned();
    }
    if let Ok(canonical_cwd) = cwd.canonicalize() {
        if let Ok(stripped) = candidate.strip_prefix(&canonical_cwd) {
            return stripped.to_string_lossy().into_owned();
        }
    }
    path.to_owned()
}

fn session_from(value: &Value, key: &str) -> String {
    value
        .get(key)
        .and_then(Value::as_str)
        .unwrap_or("anonymous-session")
        .to_owned()
}

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()]
}