pushkin-core 0.2.0

Core envelope, manifest, pipeline, and waiver types for the pushkin write-gate
Documentation
//! F48 Phase B, B1 — Claude's `Edit`/`MultiEdit` application semantics, pinned
//! BEFORE any of it is wired into a gate.
//!
//! COMMITTED RED. A NEW file per N10.
//!
//! **Why this suite exists and why it comes first.** After Phase B the gate
//! stops refusing content-absent mutations and starts judging a RECONSTRUCTION
//! of the post-edit file. If the reconstruction is wrong — a mis-counted
//! occurrence, edits applied out of order, a silently-skipped failure — the gate
//! renders 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 the posture Phase A established, silently.** Everything still
//! looks green. So the application rules are specified here, as tests against a
//! PURE function, before that function is allowed anywhere near a verdict.
//!
//! The function under test takes (content, edits) and returns the new content
//! or a typed error. No filesystem, no manifest, no gate.
//!
//! **Every error is a DENY at the gate, never a fall-through.** The failure
//! direction Phase A established does not move: if we cannot reconstruct the
//! file faithfully, we refuse rather than guess. Those tests live with the
//! wiring; what is pinned here is that each failure is *reported as an error*
//! rather than silently producing a plausible-looking string.

use pushkin_core::edits::{apply_edits, EditError, Replacement};

fn edit(old: &str, new: &str) -> Replacement {
    Replacement {
        old: old.to_owned(),
        new: new.to_owned(),
        replace_all: false,
        anchor: None,
    }
}

fn edit_all(old: &str, new: &str) -> Replacement {
    Replacement {
        old: old.to_owned(),
        new: new.to_owned(),
        replace_all: true,
        anchor: None,
    }
}

// ---------------------------------------------------------------------------
// Single edit — the occurrence rules
// ---------------------------------------------------------------------------

#[test]
fn a_unique_match_is_replaced() {
    let out = apply_edits(
        "let a = 1;\nlet b = 2;\n",
        &[edit("let a = 1;", "let a = 9;")],
    );

    assert_eq!(out.unwrap(), "let a = 9;\nlet b = 2;\n");
}

/// The rule that makes single-edit application safe: if the target is not
/// unique, the tool cannot know which one was meant, so it refuses rather than
/// picking. Replacing the first occurrence would be the plausible guess, and it
/// is exactly the guess that produces a file the author never wrote.
#[test]
fn a_non_unique_match_without_replace_all_is_an_error() {
    let err = apply_edits("x = 1;\nx = 1;\n", &[edit("x = 1;", "x = 2;")]).unwrap_err();

    assert!(
        matches!(err, EditError::NotUnique { count: 2, .. }),
        "expected NotUnique{{count:2}}, got {err:?}"
    );
}

#[test]
fn replace_all_replaces_every_occurrence() {
    let out = apply_edits("x = 1;\nx = 1;\n", &[edit_all("x = 1;", "x = 2;")]);

    assert_eq!(out.unwrap(), "x = 2;\nx = 2;\n");
}

#[test]
fn a_missing_target_is_an_error() {
    let err = apply_edits("let a = 1;\n", &[edit("nope", "yes")]).unwrap_err();

    assert!(
        matches!(err, EditError::NotFound { .. }),
        "expected NotFound, got {err:?}"
    );
}

/// `replace_all` does not excuse a missing target — zero occurrences is still a
/// failed edit, and silently returning the file unchanged would tell the gate
/// the edit succeeded.
#[test]
fn replace_all_with_no_match_is_still_an_error() {
    let err = apply_edits("let a = 1;\n", &[edit_all("nope", "yes")]).unwrap_err();

    assert!(
        matches!(err, EditError::NotFound { .. }),
        "expected NotFound, got {err:?}"
    );
}

/// An edit that changes nothing is a caller mistake, not a no-op to absorb.
/// Absorbing it would let a malformed payload look like a successful edit.
#[test]
fn an_edit_whose_old_and_new_are_identical_is_an_error() {
    let err = apply_edits("let a = 1;\n", &[edit("let a = 1;", "let a = 1;")]).unwrap_err();

    assert!(matches!(err, EditError::NoOp), "expected NoOp, got {err:?}");
}

/// Empty `old_string` has no meaningful occurrence count — every position
/// matches. Refuse rather than invent a rule.
#[test]
fn an_empty_target_is_an_error() {
    let err = apply_edits("let a = 1;\n", &[edit("", "x")]).unwrap_err();

    assert!(
        matches!(err, EditError::EmptyTarget),
        "expected EmptyTarget, got {err:?}"
    );
}

// ---------------------------------------------------------------------------
// MultiEdit — sequential application
// ---------------------------------------------------------------------------

/// The rule that makes `MultiEdit` predictable: each edit applies to the RESULT
/// of the previous one, not to the original file. A naive implementation that
/// applies every edit to the original would produce a different file whenever
/// two edits overlap.
#[test]
fn edits_apply_sequentially_to_the_running_result() {
    let out = apply_edits("one\n", &[edit("one", "two"), edit("two", "three")]);

    assert_eq!(
        out.unwrap(),
        "three\n",
        "the second edit must see the first edit's output"
    );
}

/// The consequence of sequential application, pinned so nobody 'optimises' it
/// into parallel application later: an edit whose target a PREVIOUS edit
/// destroyed fails, and the whole application fails with it.
#[test]
fn an_edit_whose_target_a_previous_edit_destroyed_is_an_error() {
    let err = apply_edits("one\n", &[edit("one", "two"), edit("one", "three")]).unwrap_err();

    assert!(
        matches!(err, EditError::NotFound { .. }),
        "the second edit's target is gone by the time it runs: {err:?}"
    );
}

/// Application is ATOMIC. A failure part-way through discards everything —
/// returning a partially-applied file would hand the gate a reconstruction
/// matching no state the editor would ever have produced.
#[test]
fn a_failure_part_way_through_discards_the_whole_application() {
    let err = apply_edits(
        "alpha\nbeta\n",
        &[edit("alpha", "ALPHA"), edit("missing", "x")],
    )
    .unwrap_err();

    assert!(
        matches!(err, EditError::NotFound { .. }),
        "expected the second edit to fail: {err:?}"
    );
}

#[test]
fn uniqueness_is_evaluated_against_the_running_result_not_the_original() {
    // After the first edit there are two "b" lines, so the second edit's
    // target is ambiguous even though it was unique in the original.
    let err = apply_edits("a\nb\n", &[edit("a", "b"), edit("b", "c")]).unwrap_err();

    assert!(
        matches!(err, EditError::NotUnique { count: 2, .. }),
        "uniqueness must be judged after prior edits: {err:?}"
    );
}

#[test]
fn an_empty_edit_list_is_an_error() {
    let err = apply_edits("anything\n", &[]).unwrap_err();

    assert!(
        matches!(err, EditError::NoEdits),
        "a mutation naming no edits is malformed, not a no-op: {err:?}"
    );
}

// ---------------------------------------------------------------------------
// Fidelity details that a reconstruction must not quietly change
// ---------------------------------------------------------------------------

/// Replacement is literal, never regex or glob. A target containing regex
/// metacharacters matches itself and nothing else.
#[test]
fn matching_is_literal_not_regex() {
    let out = apply_edits("a.c\nabc\n", &[edit("a.c", "X")]);

    assert_eq!(
        out.unwrap(),
        "X\nabc\n",
        "`.` is a literal dot here, so `abc` must be untouched"
    );
}

/// Multi-line targets are ordinary strings; nothing is line-oriented.
#[test]
fn a_multi_line_target_is_replaced_as_written() {
    let out = apply_edits(
        "fn main() {\n    body();\n}\n",
        &[edit("fn main() {\n    body();\n}", "fn main() {}")],
    );

    assert_eq!(out.unwrap(), "fn main() {}\n");
}

/// Trailing-newline handling is a classic silent corruption: a reconstruction
/// that drops or adds one produces a file that differs from what the editor
/// wrote, and every content rule then judges the wrong bytes.
#[test]
fn surrounding_bytes_including_the_trailing_newline_are_preserved() {
    let out = apply_edits("head\nmid\ntail\n", &[edit("mid", "MID")]).unwrap();

    assert_eq!(out, "head\nMID\ntail\n");
    assert!(out.ends_with('\n'), "the trailing newline must survive");
}

#[test]
fn a_file_with_no_trailing_newline_keeps_not_having_one() {
    let out = apply_edits("head\nmid", &[edit("mid", "MID")]).unwrap();

    assert_eq!(out, "head\nMID");
    assert!(!out.ends_with('\n'), "no newline must be invented");
}

/// The empty file is a real case — a create-shaped edit targets nothing.
#[test]
fn an_edit_against_empty_content_reports_not_found() {
    let err = apply_edits("", &[edit("x", "y")]).unwrap_err();

    assert!(
        matches!(err, EditError::NotFound { .. }),
        "expected NotFound, got {err:?}"
    );
}