pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! The Hermes family (R2, charter §6). Moved verbatim from the pre-R2
//! `agents.rs`. Hermes matches fuzzily, so `hermes_replacements` reconstructs on
//! deliberately narrower terms than any other family (see its doc).

use pushkin_core::edits::Replacement;
use serde_json::Value;

use super::super::shared::{relativize, session_from};
use super::super::{FileWrite, Intent, ParseOutcome, ToolAction};

/// Hermes `pre_tool_call`: `tool_name` + `tool_input.path`/`content` (addendum §3.5).
pub(crate) 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 tool_name = value.get("tool_name").and_then(Value::as_str);

    // F49 — hermes edits through `patch`, which carries `old_string`/
    // `new_string` and no file content. Captured live 2026-08-17 (fixture
    // `docs/e1b-capture/fixtures/hermes-20260817T230624Z-0012.json`); the shape
    // was UNKNOWN in-repo until then, and the charter made building against a
    // guessed shape a stopping condition. Recognized before the write parse
    // below, which would otherwise call it malformed and fall through to a
    // silent allow.
    //
    // F48 Phase B (2026-08-18) now reconstructs from those fields — but on
    // NARROWER terms than any other family, because hermes matches fuzzily.
    // See `hermes_replacements`. The `mode` field remains unenumerated, so an
    // unknown mode yields no edits and routes to the refusal.
    if tool_name == Some("patch") {
        return Ok(ToolAction {
            session,
            files: vec![FileWrite {
                path,
                content: String::new(),
                edits: hermes_replacements(tool_input),
            }],
            is_stop: false,
            intent: Intent::MutateNoContent("patch"),
            command: None,
        });
    }

    let well_formed = tool_name.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(),
                edits: Vec::new(),
            }],
            is_stop: false,
            intent: Intent::Write,
            command: None,
        }),
        _ => Err(Some(path)),
    }
}

/// F48 Phase B, hermes arm — the replacement a `patch` call describes, on
/// deliberately narrower terms than any other family gets.
///
/// **hermes matches FUZZILY, across nine strategies**, by design, so that
/// "minor whitespace/indentation differences won't break it". Every other
/// family locates its target by exact string match, which is reproducible byte
/// for byte; hermes' matcher is a component we do not have and will not
/// reimplement. So this arm reconstructs only what it can reproduce EXACTLY,
/// and `apply_edits` refuses the rest.
///
/// The cost is real and is accepted rather than hidden: pushkin refuses edits
/// hermes itself would have applied, whenever `old_string` drifts from the file
/// by so much as a space. Approximating instead would judge a file that never
/// existed — the F51 false-verdict class — which is worse in kind, not degree.
///
/// Two rules invert relative to the other arms, both toward refusal:
///
/// - `replace_all` yields NO edits here, where Claude's and opencode's arms
///   honor it. Under fuzzy matching "all" includes near-matches that cannot be
///   enumerated from here, so an exact replace-all would synthesize a file with
///   FEWER edits than hermes will actually make, and then judge it.
/// - An unknown `mode` yields no edits. The captured payload carries
///   `mode: "replace"`; the documented signature carries no mode at all; no
///   enumeration of the other values exists in the docs or in any capture.
///   Building against a guessed shape is the charter's stopping condition, so an
///   unestablished mode is refused rather than assumed to be a replacement.
pub(crate) fn hermes_replacements(tool_input: &Value) -> Vec<Replacement> {
    match tool_input.get("mode").and_then(Value::as_str) {
        // Absent is the documented signature; "replace" is what was captured.
        None | Some("replace") => {}
        Some(_) => return Vec::new(),
    }
    if tool_input.get("replace_all").and_then(Value::as_bool) == Some(true) {
        return Vec::new();
    }
    let Some(old) = tool_input.get("old_string").and_then(Value::as_str) else {
        return Vec::new();
    };
    let Some(new) = tool_input.get("new_string").and_then(Value::as_str) else {
        return Vec::new();
    };
    vec![Replacement {
        old: old.to_owned(),
        new: new.to_owned(),
        // Never true for hermes: see the `replace_all` note above.
        replace_all: false,
        // hermes sends no line numbers, so there is nothing to anchor to — the
        // exact-and-unique requirement is the whole of the location rule.
        anchor: None,
    }]
}