pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! The opencode family (R2, charter §6). Moved verbatim from the pre-R2
//! `agents.rs`. opencode relays the plugin's hook input; its `apply_patch` goes
//! through the shared F59 grammar so it and codex cannot diverge.

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

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

/// F48 Phase B, opencode arm — the replacement an `edit` call describes.
///
/// opencode's `edit` is Claude's `Edit` under different field names:
/// `oldString` / `newString` / optional `replaceAll`, defaulting to false, with
/// a not-found target and a non-unique target both errors. Established from
/// opencode's own tool schema and prompt text, not inferred from a capture.
///
/// `replaceAll` therefore defaults to FALSE here and must keep doing so.
/// Defaulting it to true would make an ambiguous edit succeed by replacing every
/// occurrence — synthesizing a file opencode itself would have refused to write,
/// and then judging it confidently. That is the F51 false-verdict class, and it
/// would arrive through a one-word mistake.
///
/// Returns an empty list — read by the gate as "cannot reconstruct", never as
/// "no changes" — for any payload that is not this shape.
pub(crate) fn opencode_replacements(args: &Value) -> Vec<Replacement> {
    let Some(old) = args.get("oldString").and_then(Value::as_str) else {
        return Vec::new();
    };
    let Some(new) = args.get("newString").and_then(Value::as_str) else {
        return Vec::new();
    };
    vec![Replacement {
        old: old.to_owned(),
        new: new.to_owned(),
        replace_all: args
            .get("replaceAll")
            .and_then(Value::as_bool)
            .unwrap_or(false),
        // Located by string search alone: opencode sends no line numbers, so a
        // repeated target genuinely is ambiguous, exactly as for Claude.
        anchor: None,
    }]
}

/// 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.
pub(crate) fn normalize_opencode(value: &Value) -> ParseOutcome {
    let session = session_from(value, "sessionID");
    let Some(args) = value.get("args") else {
        return Err(None);
    };
    let tool = value.get("tool").and_then(Value::as_str);

    // F59 — opencode's OTHER native mutation tool. It carries `patchText` and
    // names no `filePath`, so the extraction below called it unparseable and the
    // gate answered `{"decision":"allow"}` — on this family the fail-open is an
    // AFFIRMED write, not merely an unnoticed one, because opencode's verdict
    // channel is explicit and its plugin acts on what it is handed.
    //
    // Recognized before the extraction, the same ordering rule every other arm
    // in this file uses, and routed through the SHARED patch path so codex and
    // opencode cannot reach different verdicts on identical patch text.
    if tool == Some("apply_patch") {
        if let Some(patch) = args.get("patchText").and_then(Value::as_str) {
            return patch_action(session, patch);
        }
    }

    let path = args
        .get("filePath")
        .and_then(Value::as_str)
        .map(relativize)
        .ok_or(None)?;

    // SPIKE — opencode's read tool is `read` with `args.filePath` (the
    // `.env`-protection example in the plugin docs is exactly this shape).
    // Same ordering rule as the Claude family: recognize the read before
    // the write parse, which treats absent content as malformed.
    if tool == Some("read") {
        let bounded = args.get("offset").is_some() || args.get("limit").is_some();
        return Ok(ToolAction {
            session,
            files: vec![FileWrite {
                path,
                content: String::new(),
                edits: Vec::new(),
            }],
            is_stop: false,
            intent: if bounded {
                Intent::ReadRange
            } else {
                Intent::ReadWhole
            },
            command: None,
        });
    }

    // F50 — opencode edits through `edit`, whose `args` carry
    // `oldString`/`newString` and no content. Captured live 2026-08-17
    // (fixture `docs/e1b-capture/fixtures/opencode-20260817T230641Z-0014.json`).
    // Same ordering rule as the `read` arm above, and for the same reason.
    if tool == Some("edit") {
        return Ok(ToolAction {
            session,
            files: vec![FileWrite {
                path,
                content: String::new(),
                edits: opencode_replacements(args),
            }],
            is_stop: false,
            intent: Intent::MutateNoContent("edit"),
            command: None,
        });
    }

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