pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Cross-family adapter primitives (R2, charter 2026-08-20-r2-agent-families §6).
//!
//! Moved verbatim from the pre-R2 `agents.rs`. This is the spine two or more
//! families share: `relativize`/`session_from` (every normalizer) and the F59
//! `apply_patch` grammar that codex AND opencode speak — kept single-sourced
//! here so the two can never drift to different verdicts on identical patch text
//! (the F59 invariant). Nothing agent-specific lives here.

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

use super::{FileWrite, Intent, ParseOutcome, ToolAction};

/// F59 — the shared `apply_patch` verdict path. ONE grammar, one
/// implementation, for every family that speaks it.
///
/// codex sends this format in `tool_input.command`; opencode sends the
/// identical format in `args.patchText`. Giving each adapter its own copy of
/// the parse-and-classify logic is how two adapters drift apart on one format,
/// and the drift shows up as a verdict difference on the same bytes — so the
/// two call this instead.
pub(crate) fn patch_action(session: String, command: &str) -> ParseOutcome {
    let PatchSections {
        files,
        has_update,
        has_add,
        has_delete,
    } = parse_apply_patch(command);
    if files.is_empty() {
        // A patch we cannot parse still names no target: fail-open path.
        return Err(None);
    }
    // F52 — an Update section is a HUNK, so its `+` lines are a fragment of the
    // resulting file, not the file. Judging them as content produced a live
    // SILENT ALLOW on a mapped path whose handler was unvalidated (2026-08-17).
    //
    // A patch mixing Add and Update refuses on ALL its paths: a `ToolAction`
    // carries one intent for every file it names, and for an interim
    // fail-closed posture over-refusing is the correct direction. Phase B,
    // which synthesizes per file, is where the compromise ends.
    //
    // F58 — the order below is by how much each intent JUDGES, not by which
    // marker appeared first, because one `ToolAction` carries one intent for
    // every file it names. `Delete` is last for that reason: it runs the path
    // rules alone, so choosing it for a patch that also ADDS a file would let
    // the added content skip the content rules entirely — trading one fail-open
    // for another. A delete inside an Add or Update patch still gets its path
    // rules, since those run on every file under every intent here.
    let intent = if has_update {
        Intent::MutateNoContent("apply_patch")
    } else if has_add || !has_delete {
        Intent::Write
    } else {
        Intent::Delete
    };
    Ok(ToolAction {
        session,
        files,
        is_stop: false,
        intent,
        command: None,
    })
}

/// Parses (path, added-content) pairs from a Codex `apply_patch` command string.
/// F52 — whether a patch carries whole files or hunks.
///
/// `*** Add File:` sections are entirely `+` lines, so the collected text IS
/// the new file and the content rules can judge it. `*** Update File:` sections
/// are HUNKS: the `+` lines are a fragment of the resulting file, and judging
/// them as though they were the file is the same false-verdict class as F51 —
/// observed live as a SILENT ALLOW on a mapped path whose real handler was
/// unvalidated.
pub(crate) struct PatchSections {
    files: Vec<FileWrite>,
    has_update: bool,
    has_add: bool,
    has_delete: bool,
}

/// A write target carrying no body of its own: a deleted file, or a rename
/// DESTINATION. Both are gated on their path alone (F58).
pub(crate) fn bare_target(path: &str) -> FileWrite {
    FileWrite {
        path: relativize(path.trim()),
        content: String::new(),
        edits: Vec::new(),
    }
}

/// The lines one hunk expects to find, and the lines it leaves behind (F52).
#[derive(Default)]
pub(crate) struct Hunk {
    old: Vec<String>,
    new: Vec<String>,
}

/// One `*** ... File:` section as it is being read.
pub(crate) struct Section {
    path: String,
    /// `*** Add File:` sections only — the whole new file, from its `+` lines.
    /// An Update section leaves this EMPTY: a hunk's `+` lines are a fragment,
    /// and treating them as the file was the original F52 defect.
    content: String,
    is_add: bool,
    edits: Vec<Replacement>,
    hunk: Option<Hunk>,
    /// A rename withholds CONTENT synthesis: the reconstructed bytes land at
    /// the destination while the source ceases to exist, so judging them
    /// against either path alone is a false verdict. F58's path rules still
    /// gate both ends.
    moved: bool,
    /// A line inside the section that fits none of the format's shapes. The
    /// section is then not understood, and silently dropping the line would
    /// reconstruct a file missing part of the change.
    malformed: bool,
}

impl Section {
    fn new(path: &str, is_add: bool) -> Self {
        Self {
            path: relativize(path.trim()),
            content: String::new(),
            is_add,
            edits: Vec::new(),
            hunk: None,
            moved: false,
            malformed: false,
        }
    }

    /// F52 — a hunk is a REPLACEMENT in disguise. Codex carries no line
    /// numbers, so a hunk is located by context: the lines it expects to find
    /// (context + deletions, in order) are the target, and the lines it leaves
    /// behind (context + additions, in order) are the replacement.
    fn close_hunk(&mut self) {
        let Some(hunk) = self.hunk.take() else {
            return;
        };
        if hunk.old.is_empty() && hunk.new.is_empty() {
            return;
        }
        self.edits.push(Replacement {
            old: hunk.old.join("\n"),
            new: hunk.new.join("\n"),
            // A hunk names one site. `apply_edits` refuses a target that is not
            // unique, which is exactly the right answer once the `@@` scope
            // header has been discarded as a locator rather than as text.
            replace_all: false,
            anchor: None,
        });
    }

    fn read(&mut self, line: &str) {
        // `@@[ scope]` opens a hunk. Anything after the marker NAMES an
        // enclosing scope to disambiguate — a locator hint, never text adjacent
        // to the hunk — so it is dropped. A hunk left ambiguous without it
        // refuses rather than picking an occurrence.
        if line.starts_with("@@") {
            self.close_hunk();
            self.hunk = Some(Hunk::default());
            return;
        }
        if let Some(added) = line.strip_prefix('+') {
            if self.is_add {
                self.content.push_str(added);
                self.content.push('\n');
            } else {
                self.hunk
                    .get_or_insert_with(Hunk::default)
                    .new
                    .push(added.to_owned());
            }
            return;
        }
        if self.is_add {
            // An Add section is entirely `+` lines by construction.
            self.malformed = !line.is_empty();
            return;
        }
        if let Some(removed) = line.strip_prefix('-') {
            self.hunk
                .get_or_insert_with(Hunk::default)
                .old
                .push(removed.to_owned());
            return;
        }
        // Context: present on BOTH sides. An empty line is an empty context
        // line — the format's leading space is often trimmed off a blank one.
        if let Some(context) = line.strip_prefix(' ') {
            let hunk = self.hunk.get_or_insert_with(Hunk::default);
            hunk.old.push(context.to_owned());
            hunk.new.push(context.to_owned());
            return;
        }
        if line.is_empty() {
            let hunk = self.hunk.get_or_insert_with(Hunk::default);
            hunk.old.push(String::new());
            hunk.new.push(String::new());
            return;
        }
        self.malformed = true;
    }

    fn finish(mut self) -> FileWrite {
        self.close_hunk();
        let edits = if self.moved || self.malformed {
            Vec::new()
        } else {
            self.edits
        };
        FileWrite {
            path: self.path,
            content: self.content,
            edits,
        }
    }
}

pub(crate) fn close_section(files: &mut Vec<FileWrite>, section: Option<Section>) {
    if let Some(section) = section {
        files.push(section.finish());
    }
}

pub(crate) fn parse_apply_patch(command: &str) -> PatchSections {
    let mut files = Vec::new();
    let mut section: Option<Section> = None;
    let mut has_update = false;
    let mut has_add = false;
    let mut has_delete = false;
    for line in command.lines() {
        if let Some(path) = line.strip_prefix("*** Add File: ") {
            close_section(&mut files, section.take());
            has_add = true;
            section = Some(Section::new(path, true));
        } else if let Some(path) = line.strip_prefix("*** Update File: ") {
            close_section(&mut files, section.take());
            has_update = true;
            section = Some(Section::new(path, false));
        } else if let Some(path) = line.strip_prefix("*** Delete File: ") {
            // F58 — a delete names its target and carries no body, so it is
            // pushed immediately rather than opening a section. Before that
            // finding it matched no marker at all and the whole patch fell open.
            close_section(&mut files, section.take());
            has_delete = true;
            files.push(bare_target(path));
        } else if let Some(path) = line.strip_prefix("*** Move to: ") {
            // F58 — the rename DESTINATION is a write target in its own right.
            // F52 — and it withholds this section's content synthesis.
            files.push(bare_target(path));
            if let Some(open) = section.as_mut() {
                open.moved = true;
            }
        } else if line.starts_with("*** End of File") {
            // A locator marker: it says the hunk sits at EOF. Nothing to read.
        } else if line.starts_with("*** End Patch") {
            close_section(&mut files, section.take());
        } else if let Some(open) = section.as_mut() {
            open.read(line);
        }
    }
    close_section(&mut files, section.take());
    PatchSections {
        files,
        has_update,
        has_add,
        has_delete,
    }
}

/// 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).
pub(crate) 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()
}

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