# SPDX-License-Identifier: MIT """Tests for tool_patch_file. Standard unified diffs carry context lines. The old implementation ignored them and removed lines counted from the hunk start, so a diff that changed line 2 deleted line 1 instead and still reported success. """ import os import sys import pytest sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) import trashclaw @pytest.fixture(autouse=True) def isolate(tmp_path, monkeypatch): monkeypatch.setattr(trashclaw, "CWD", str(tmp_path)) monkeypatch.setattr(trashclaw, "UNDO_STACK", []) return tmp_path def _write(tmp_path, text, name="f.txt"): p = tmp_path / name p.write_text(text) return p def test_context_lines_are_honoured(tmp_path): p = _write(tmp_path, "a\nb\nc\n") patch = "--- a/f.txt\n+++ b/f.txt\n@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n" result = trashclaw.tool_patch_file(str(p), patch) assert result.startswith("Patched") assert p.read_text() == "a\nB\nc\n" def test_mismatched_hunk_is_rejected_and_file_untouched(tmp_path): p = _write(tmp_path, "a\nb\nc\n") result = trashclaw.tool_patch_file(str(p), "@@ -1,3 +1,3 @@\n a\n-x\n+X\n c\n") assert result.startswith("Error") assert "No changes written" in result assert p.read_text() == "a\nb\nc\n" assert trashclaw.UNDO_STACK == [] # nothing was changed, nothing to undo def test_partial_failure_writes_nothing(tmp_path): original = "".join(f"l{i}\n" for i in range(1, 11)) p = _write(tmp_path, original) patch = ("@@ -1,2 +1,2 @@\n l1\n-l2\n+L2\n" "@@ -8,2 +8,2 @@\n l8\n-nope\n+NOPE\n") result = trashclaw.tool_patch_file(str(p), patch) assert "hunk 2 of 2" in result assert p.read_text() == original def test_multiple_hunks_with_line_count_changes(tmp_path): p = _write(tmp_path, "".join(f"l{i}\n" for i in range(1, 21))) patch = ("@@ -1,3 +1,3 @@\n l1\n-l2\n+L2\n l3\n" "@@ -9,3 +9,4 @@\n l9\n-l10\n+L10\n+new\n l11\n") result = trashclaw.tool_patch_file(str(p), patch) assert "2 hunks, 3 additions, 2 removals" in result lines = p.read_text().splitlines() assert lines[:3] == ["l1", "L2", "l3"] assert lines[8:12] == ["l9", "L10", "new", "l11"] assert len(lines) == 21 def test_drifted_line_numbers_still_apply(tmp_path): p = _write(tmp_path, "x\ny\na\nb\nc\n") result = trashclaw.tool_patch_file(str(p), "@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n") assert result.startswith("Patched") assert p.read_text() == "x\ny\na\nB\nc\n" def test_blank_context_line_without_leading_space(tmp_path): p = _write(tmp_path, "def f():\n\n return 1\n") patch = "@@ -1,3 +1,3 @@\n def f():\n\n- return 1\n+ return 2\n" result = trashclaw.tool_patch_file(str(p), patch) assert result.startswith("Patched") assert p.read_text() == "def f():\n\n return 2\n" def test_pure_insertion_after_line(tmp_path): p = _write(tmp_path, "a\nb\n") result = trashclaw.tool_patch_file(str(p), "@@ -2,0 +3,1 @@\n+c\n") assert result.startswith("Patched") assert p.read_text() == "a\nb\nc\n" def test_create_new_file(tmp_path): p = tmp_path / "new.txt" result = trashclaw.tool_patch_file(str(p), "--- /dev/null\n+++ b/new.txt\n@@ -0,0 +1,2 @@\n+x\n+y\n") assert result.startswith("Patched") assert p.read_text() == "x\ny\n" def test_patch_without_hunks_is_an_error(tmp_path): p = _write(tmp_path, "a\n") result = trashclaw.tool_patch_file(str(p), "just some text") assert result.startswith("Error") assert p.read_text() == "a\n" def test_successful_patch_is_undoable(tmp_path): p = _write(tmp_path, "a\nb\n") trashclaw.tool_patch_file(str(p), "@@ -1,2 +1,2 @@\n a\n-b\n+B\n") assert trashclaw.UNDO_STACK[-1]["content"] == "a\nb\n" assert trashclaw.UNDO_STACK[-1]["action"] == "patch" # --- Review follow-ups: each case below was reported as "Patched" before. --- def _assert_refused(p, result, original): assert result.startswith("Error"), result assert "No changes written" in result assert p.read_text() == original assert trashclaw.UNDO_STACK == [] def test_removed_and_added_lines_that_look_like_file_headers(tmp_path): # "-- old" / "++ new" (Lua/SQL comments) show up in the diff as # "--- old" / "+++ new"; the hunk counts say they are body lines. p = _write(tmp_path, "a\nb\n-- x\nc\n") result = trashclaw.tool_patch_file(str(p), "@@ -2,2 +2,2 @@\n-b\n+B\n--- x\n+++ y\n") assert result.startswith("Patched"), result assert p.read_text() == "a\nB\n++ y\nc\n" def test_hunk_shorter_than_header_is_refused(tmp_path): p = _write(tmp_path, "a\nb\nc\n") result = trashclaw.tool_patch_file(str(p), "@@ -1,3 +1,3 @@\n a\n-b\n+B\n") _assert_refused(p, result, "a\nb\nc\n") assert "-3/+3" in result def test_hunk_longer_than_header_is_refused(tmp_path): p = _write(tmp_path, "a\nb\nc\n") result = trashclaw.tool_patch_file(str(p), "@@ -1,2 +1,2 @@\n a\n-b\n+B\n c\n") _assert_refused(p, result, "a\nb\nc\n") def test_trailing_blank_context_line_is_verified(tmp_path): p = _write(tmp_path, "a\nX\n") result = trashclaw.tool_patch_file(str(p), "@@ -1,2 +1,2 @@\n-a\n+A\n \n") _assert_refused(p, result, "a\nX\n") q = _write(tmp_path, "a\n\n", name="g.txt") assert trashclaw.tool_patch_file(str(q), "@@ -1,2 +1,2 @@\n-a\n+A\n \n").startswith("Patched") assert q.read_text() == "A\n\n" def test_drift_is_carried_into_later_insertion(tmp_path): p = _write(tmp_path, "x\na\nb\n") result = trashclaw.tool_patch_file(str(p), "@@ -1 +1 @@\n-a\n+A\n@@ -2,0 +3,1 @@\n+c\n") assert result.startswith("Patched"), result assert p.read_text() == "x\nA\nb\nc\n" def test_out_of_range_insertion_is_refused(tmp_path): p = _write(tmp_path, "a\nb\n") result = trashclaw.tool_patch_file(str(p), "@@ -999,0 +1000,1 @@\n+z\n") _assert_refused(p, result, "a\nb\n") def test_insertion_after_last_line_without_newline(tmp_path): p = _write(tmp_path, "a\nb") result = trashclaw.tool_patch_file(str(p), "@@ -2,0 +3,1 @@\n+c\n") assert result.startswith("Patched"), result assert p.read_text() == "a\nb\nc\n" def test_multi_file_diff_is_refused(tmp_path): p = _write(tmp_path, "a\n") patch = ("--- a/f.txt\n+++ b/f.txt\n@@ -1 +1 @@\n-a\n+A\n" "--- a/g.txt\n+++ b/g.txt\n@@ -1 +1 @@\n-A\n+Z\n") result = trashclaw.tool_patch_file(str(p), patch) _assert_refused(p, result, "a\n") assert "more than one file" in result