pushkin-core 0.2.0

Core envelope, manifest, pipeline, and waiver types for the pushkin write-gate
Documentation
//! F48 Phase B — applying an editor's edit operations to file content.
//!
//! A PURE function: (content, edits) → new content, or a typed error. No
//! filesystem, no manifest, no gate. The gate layer reads the file and decides
//! what to do with the result; this module only reconstructs.
//!
//! **Why it is pure, and why it is specified before it is used.** After Phase B
//! the gate judges a RECONSTRUCTION of the post-edit file rather than refusing
//! to judge at all. A reconstruction that is subtly wrong produces a confident
//! verdict on a file that never existed — and a real violation in the true
//! post-edit content passes, because it was never in the synthesized content.
//! That inverts Phase A's posture silently, with everything still green. So the
//! rules live here, pinned by `tests/edit_application.rs`, testable without a
//! gate anywhere near them.
//!
//! **Every error is a refusal, never a fallback.** A caller that cannot
//! reconstruct the file faithfully must deny, exactly as Phase A denied when
//! there was no content at all. The failure direction does not move.

/// A 1-based, INCLUSIVE line range: the region of the file a family claims its
/// target sits in.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LineSpan {
    pub start: usize,
    pub end: usize,
}

/// One replacement, as an editor's edit tool describes it.
#[derive(Debug, Clone)]
pub struct Replacement {
    pub old: String,
    pub new: String,
    /// When false, the target must occur EXACTLY once or the edit is refused.
    /// Ignored when `anchor` is set: an anchored edit is positional, so
    /// "everywhere" has no meaning for it.
    pub replace_all: bool,
    /// F48 Phase B, auggie arm — where the family says the target is. When
    /// present the search is CONFINED to these lines, which is what makes a
    /// repeated target unambiguous: `body` may occur all over the file and
    /// still occur exactly once inside its own anchor.
    ///
    /// `None` for families that locate by string search alone (Claude's
    /// `Edit`/`MultiEdit`), where a repeated target genuinely is ambiguous.
    pub anchor: Option<LineSpan>,
}

/// Why a reconstruction could not be produced. Each variant is a deny at the
/// gate; none of them may be absorbed into a plausible-looking string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EditError {
    /// The target does not occur in the content the edit was applied to. With
    /// `MultiEdit` this includes a target a PREVIOUS edit destroyed.
    NotFound { old: String },
    /// The target occurs more than once and `replace_all` was not set, so the
    /// tool cannot know which was meant. Replacing the first is the plausible
    /// guess and precisely the guess that fabricates a file.
    NotUnique { old: String, count: usize },
    /// `old` and `new` are identical — a caller mistake, not a no-op to absorb.
    NoOp,
    /// An empty target has no meaningful occurrence count; every position
    /// matches.
    EmptyTarget,
    /// A mutation naming no edits is malformed, not a no-op.
    NoEdits,
    /// The anchor names lines the file does not have, or an inverted range. The
    /// family is describing a file we are not looking at.
    AnchorOutOfRange {
        start: usize,
        end: usize,
        lines: usize,
    },
    /// An anchored edit follows one that changed the file's line count, so its
    /// coordinates are ambiguous between two readings the capture does not
    /// distinguish: original-file numbering, or numbering in the file as the
    /// previous edits left it. Both agree until a line is added or removed, and
    /// then they disagree silently. Refusing is the only honest answer until a
    /// capture settles it.
    AnchorShifted,
}

impl std::fmt::Display for EditError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::NotFound { old } => {
                write!(f, "no occurrence of {old:?} in the file being edited")
            }
            Self::NotUnique { old, count } => write!(
                f,
                "{old:?} occurs {count} times and replace_all was not set; \
                 the intended occurrence is ambiguous"
            ),
            Self::NoOp => write!(f, "the replacement is identical to the target"),
            Self::EmptyTarget => write!(f, "the target is empty and matches everywhere"),
            Self::NoEdits => write!(f, "the mutation carries no edits"),
            Self::AnchorOutOfRange { start, end, lines } => write!(
                f,
                "the edit is anchored to lines {start}-{end}, which the file \
                 (of {lines} lines) does not have"
            ),
            Self::AnchorShifted => write!(
                f,
                "an earlier edit changed the file's line count, so this edit's \
                 line anchor is ambiguous and cannot be honored"
            ),
        }
    }
}

impl std::error::Error for EditError {}

/// Applies `edits` to `content`, in order, each against the RESULT of the
/// previous one — the semantics `MultiEdit` documents. Atomic: any failure
/// discards the whole application rather than returning a partially-edited
/// file, which would match no state the editor could produce.
///
/// # Errors
/// Returns `EditError` when the reconstruction cannot be produced faithfully.
pub fn apply_edits(content: &str, edits: &[Replacement]) -> Result<String, EditError> {
    if edits.is_empty() {
        return Err(EditError::NoEdits);
    }
    // Applied to a running copy, and only returned once every edit has
    // succeeded — the atomicity the suite pins. A caller must never receive a
    // partially-edited file.
    let mut current = content.to_owned();
    let mut line_count_changed = false;
    for edit in edits {
        // Anchors are coordinates, and once the line count moves we no longer
        // know which file state they are coordinates IN. See `AnchorShifted`.
        if line_count_changed && edit.anchor.is_some() {
            return Err(EditError::AnchorShifted);
        }
        let before = current.lines().count();
        current = apply_one(&current, edit)?;
        line_count_changed |= current.lines().count() != before;
    }
    Ok(current)
}

/// One replacement against the content as it stands at this point in the
/// sequence. Occurrence counting therefore sees the result of every prior
/// edit, not the original file.
fn apply_one(content: &str, edit: &Replacement) -> Result<String, EditError> {
    if edit.old.is_empty() {
        return Err(EditError::EmptyTarget);
    }
    if edit.old == edit.new {
        return Err(EditError::NoOp);
    }
    if let Some(anchor) = edit.anchor {
        return apply_anchored(content, edit, anchor);
    }
    let count = content.matches(edit.old.as_str()).count();
    if count == 0 {
        return Err(EditError::NotFound {
            old: edit.old.clone(),
        });
    }
    if count > 1 && !edit.replace_all {
        return Err(EditError::NotUnique {
            old: edit.old.clone(),
            count,
        });
    }
    // `replace` is literal, never pattern-based — the property the suite pins
    // with a target containing a regex metacharacter. `replacen(.., 1)` is the
    // unique case, which by here is known to have exactly one occurrence.
    Ok(if edit.replace_all {
        content.replace(edit.old.as_str(), &edit.new)
    } else {
        content.replacen(edit.old.as_str(), &edit.new, 1)
    })
}

/// One replacement confined to its anchored lines. The occurrence rules are the
/// unanchored ones, applied to the window instead of the whole file — so an
/// anchor never RELAXES a rule, it only narrows where the rule looks. A target
/// absent from its window is `NotFound` even if it occurs elsewhere, which is
/// the point: the family told us where it is, and it is not there.
fn apply_anchored(
    content: &str,
    edit: &Replacement,
    anchor: LineSpan,
) -> Result<String, EditError> {
    let Some((from, to)) = byte_range_of_lines(content, anchor) else {
        return Err(EditError::AnchorOutOfRange {
            start: anchor.start,
            end: anchor.end,
            lines: content.lines().count(),
        });
    };
    let window = &content[from..to];
    let count = window.matches(edit.old.as_str()).count();
    if count == 0 {
        return Err(EditError::NotFound {
            old: edit.old.clone(),
        });
    }
    if count > 1 {
        return Err(EditError::NotUnique {
            old: edit.old.clone(),
            count,
        });
    }
    let mut out = String::with_capacity(content.len());
    out.push_str(&content[..from]);
    out.push_str(&window.replacen(edit.old.as_str(), &edit.new, 1));
    out.push_str(&content[to..]);
    Ok(out)
}

/// Byte range covering lines `start..=end`, 1-based inclusive, with each line's
/// terminator included. `None` for an inverted range, a zero start, or an end
/// past the last line — every one of which means the anchor describes a file
/// other than this one.
fn byte_range_of_lines(content: &str, anchor: LineSpan) -> Option<(usize, usize)> {
    if anchor.start == 0 || anchor.end < anchor.start {
        return None;
    }
    let mut offset = 0;
    let mut from = None;
    let mut to = None;
    for (index, line) in content.split_inclusive('\n').enumerate() {
        let number = index + 1;
        if number == anchor.start {
            from = Some(offset);
        }
        offset += line.len();
        if number == anchor.end {
            to = Some(offset);
        }
    }
    from.zip(to)
}